* insecure-defaults: convert the skill to a dynamic workflow Rewrites the plugin as a four-phase workflow: recon profiles the target, parallel sweeps collect candidates, a refuting pass adjudicates them, and a report assigns severity with coverage accounting. The skill is removed and `/insecure-defaults:audit [path]` is the only entry point, since the workflow needs the command to locate its detection corpora. It also adds offline tests: scenarios over the workflow's control flow, a mutation self-test that proves they bite, and a check that every documented example is matched by a seed pattern. This does not replace running the command end-to-end against a real codebase. Bumps to 2.0.0. * insecure-defaults: run the node suites in CI The harness and seed-coverage checks ran only when someone remembered to. CI's shell-suite discovery matches plugins/*/tests/run_*.sh, so wrap the three node invocations in run_seeds.sh and both the lint workflow and `make shell-suites` pick them up with no changes to either. Without this, adding a row to CATEGORIES without its references/<id>.json passes CI and then aborts every real run with corpus-unreadable. No setup-node step: ubuntu-latest ships Node, and the scripts are plain CommonJS with no dependencies. The command -v guard makes a missing interpreter a loud failure rather than a suite that quietly does not run. * insecure-defaults: abort when the verify phase adjudicates nothing If every verify batch died, confirmed was empty and the run returned no-findings-confirmed, which commands/audit.md considers a completed audit. Guard on unadjudicated.length === candidates.length and return verify-failed, carrying coverage so the caller sees what went unjudged. Adds a scenario for both failure shapes (all agents dead, all verdict lists empty) and two mutations covering the guard firing and over-firing. * insecure-defaults: report per-category scan counts The zero-scanned guard is on the sum, so five failed searches beside one that worked cleared it and categories_run listed all six. Add files_scanned_by_category and unsearched_categories to coverage, keyed off CATEGORIES so a dead sweep counts as 0, and have the report name them under a Not searched heading. Adds a scenario covering a searching sweep, a zero-file one and a dead one, plus three mutations. * insecure-defaults: count sweep failures against the category list The corpus-unreadable note and the seed-only log divided by the sweeps that returned, so with sweeps dead the ratio read 1/1 rather than 1/6. * insecure-defaults: drop the assertion-count floor from the harness * insecure-defaults: guard a dead report agent agent() returns null on terminal failure, so a report agent that died returned status "findings" with no report and the caller printed nothing while the findings sat in the structured return. Return report-failed with the findings and coverage, and have the command render them. * insecure-defaults: stop labelling a genuine clean run a failure Step 3 accepted findings/no-findings-confirmed and called every other status an incomplete audit, so no-candidates, the deliberate honest-negative status, told the user the run failed. It is now a per-status table. * insecure-defaults: anchor the noisiest seeds (DES|RC4|...) matched NODES and MODES, 0o?(666|777|...) matched any digits, and getMessage() matched all Java exception handling. On the Python stdlib the first two drop from 147 and 228 matching lines to 0 and 84. random. and getMessage() can't be fixed by anchoring, so they now require context: a security-material identifier near the RNG call, and concatenation into a string literal for getMessage(). seed-coverage.js confirms all 18 documented VULNERABLE examples still match. * insecure-defaults: indent the seed wrapper the way shfmt wants --------- Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
7.1 KiB
Tests
Offline tests for the workflows/ scripts. run_seeds.sh runs all three, and CI's shell-suite job discovers it by name (run_*.sh). Locally, make shell-suites — not make check, which excludes that target for unrelated reasons (see the Makefile note and #207).
bash plugins/insecure-defaults/tests/run_seeds.sh # everything below
cd plugins/insecure-defaults
node tests/harness.js workflows/audit.js # run the scenarios
node tests/harness.js workflows/audit.js --self-test # prove the scenarios bite
node tests/seed-coverage.js . # documented == scanned
All exit non-zero on failure. Requires node and grep; no agents are spawned and nothing touches the network.
seed-coverage.js
Checks the three places a category is defined against each other: the { id, title } row in workflows/audit.js, references/<id>.json, and references/<id>.md:
| Check | Why |
|---|---|
| Both files exist for every row, and every file belongs to a row | A stray file looks like a category that will never be swept |
.json id and title match the row |
The sweep prompt uses the row's title; a mismatch means the files disagree about what the category is |
.json has seeds, none using \s/\d/\b |
Some grep builds silently fail to match those, and a pattern matching nothing looks exactly like a clean result |
.md has a heading and both rule lines |
The Report when / Skip when rule is what the sweep applies |
| Every VULNERABLE example is matched by a seed | See below |
Nothing at runtime can do any of this: the workflow has no filesystem access, so it cannot read either file, and a sweep handed a bad definition only reports it after the run has started.
The corpus states what the plugin detects; the seeds are what goes looking. They drifted apart silently once already: 8 of 18 documented examples matched no seed in any category, including getenv(K, "default"), the most common shape in Python. Nothing in the workflow could notice: a pattern that matches nothing returns the same empty result as a clean repo.
Matching goes through grep -E, not JS RegExp, because the seeds are POSIX ERE ([[:space:]] has no JS equivalent), so this exercises them the way an agent running grep -rE would.
Three outcomes per example:
ok |
matched by its own category's seeds |
via |
matched only by a sibling category, acceptable since sweeps run in parallel and dedup merges by file:line |
GAP |
matched by nothing → fails |
It also fails if it finds zero examples or zero seeds, since a vacuous pass is the same bug it exists to catch.
What the harness does
The workflow runtime hands a script its globals (agent, parallel, pipeline, phase, log, args, budget, workflow) and wraps the body in an async function, which is why a script can use a top-level return. The harness reproduces that: strips the export, wraps, and injects stubs that return canned agent responses keyed off each agent's label.
It deliberately does not inject a workflow global. All four phases are inlined in audit.js, so a reintroduced await workflow(...) throws ReferenceError rather than quietly resolving, and one scenario asserts none remain.
That makes the parts of the workflow that aren't prompt text directly testable: argument parsing, cross-category dedup, directory-aware batching, which corpus each agent is handed, and every abort status.
A few scenarios do assert on prompt content, where a wording change would quietly narrow scope, notably that the verifier still carries the "unconditional candidates cannot be refuted at step 2" branch. Half the target set has no config fallback, so losing that sentence would silently discard it.
What it does not test: whether the prompts elicit good behaviour from a real model. Only a live run tells you that.
The --self-test mode, and why it exists
A test suite that has silently stopped checking anything reports success forever. So --self-test mutates the workflow source in memory and asserts the scenarios fail for each mutation. A sample:
| Mutation | Should break |
|---|---|
| Rule id dropped from the dedup key | unique-candidate count |
Adjudication and candidates both keyed on file:line |
one category's verdict masking another's omission |
| Verdict sets filtered before their batch is attached | the category stamp that keying depends on |
batches_verified counts dead batches too |
a partial run reading as complete |
| Per-category scan accounting dropped | a category that searched 0 files reading as covered |
| Unsearched categories counted only over sweeps that returned | a dead sweep being absent rather than at 0 |
| Total verify failure no longer aborts | verify-failed status, so a dead verify phase cannot read as clean |
verify-failed widened to any unadjudicated candidate |
a partial verify still producing its report |
| Oversized directory no longer split on its own | the over-cap branch of the packer |
| Genuine-negative status collapsed into the generic one | an honest negative staying distinguishable from a failure |
| Directory packing replaced by blind sort-then-chunk | whole-directory batching |
| Batch cap removed | chunking a category wider than 16 |
if (!pluginRoot) gate removed |
entry-guard abort |
Recon no longer told to keep the target out of exclude_paths |
auditing a scope that looks non-production |
meta.phases model drifts from the MODELS table |
per-phase model agreement |
| Corpus-unreadable abort removed | corpus-unreadable status |
Full recon leaked into a sweep prompt |
context scoping |
|| "." becomes ?? "." |
empty-string scope defaulting |
| Zero-scanned guard removed | search-failed status |
| Seed-only detection disabled | seed_only_sweeps reporting |
Verify downgraded off Opus in MODELS |
per-phase model pinning |
| Unconditional branch dropped from the ladder | config-free findings staying in scope |
Every mutation is caught, and a mutation that matches nothing is itself a failure. Otherwise a renamed variable, or reformatted code, would quietly turn a mutation into a no-op and the self-test would pass while checking nothing. That has already fired three times in practice: once when a mutated phrase also appeared in meta.whenToUse and got replaced there instead, once when the corpus-scoping line changed shape, and once when the verify agent's options were collapsed onto one line.
Adding a scenario
Append to SCENARIOS in harness.js. Each entry is { name, run(src) } returning an array of [label, boolean, detail?]. Then add a mutation to MUTATIONS that your scenario is the one to catch. A scenario with no corresponding mutation isn't proven to check anything.
A sweep fixture of null is a sweep that returned nothing and an Error is one that died; a category the fixture omits returns a default that scanned 0 files.
Fixtures derive category ids from CATEGORIES in audit.js and seeds from references/<id>.json rather than hardcoding either, so editing a category doesn't quietly make the fixtures vacuous. The suite fails if it parses zero categories.