Files
Nuno Sabino 4822dc3876 variant-analysis: convert the skill to a dynamic workflow (#232)
* Converted skill into a dynamic workflow. Still working on the tests

* Added gradio test with injected vulns

* Fix grader

* Fix trailing whitespaces

* Bump version number

* Remove trailing whitespaces from a git patch...

* Run pre-commit

* Add claude evals

* Address PR claude review

* variant-analysis: fix problems found by testing #232 before release (#237)

* variant-analysis: fix three workflow defects found in a cold run

Prose args killed the run on the first line. The model wrote
`bug: ...; root: /path; lang: python` instead of an object, and the invocation
died with `args.bug is required` before a single agent started. Parse that
shape, and say in whenToUse that args is a JSON object.

The baseline command was not shell-safe. The pattern went through
JSON.stringify, which looks like quoting but yields a double-quoted string where
$(...) and backticks still expand -- and the pattern is model-generated from
codebase content. The root was not quoted at all, so any path with a space broke
the command. Single-quote both.

The sweep had no size floor. It spawned 25 agents against a 5-file fixture,
re-reading in parallel what one agent holds at once. The eval's own negative
result already said so: five small synthetic codebases showed no difference
between the workflow and the skill alone because the fan-out had nothing to buy.
Below 40 source files, sweep two axes in one round -- 7 agents on the same
fixture. The baseline gate reports the file count, and a single-round sweep is
now reported as the deliberate bound it is rather than as a truncated one.

The report stage now has to emit `**Location:**` fields. Without them the
grader falls through to a permissive path its own docstring calls over-counting,
which is what happened on the cold run: a real report scored through the
fallback and nothing said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* variant-analysis: score construct spans, not line proximity

A cold run scored a correct report as wrong. The report flagged a helper at
lines 4 and 7 of a file whose safe site began at line 10; LINE_WINDOW=30
credited it as the safe site being reported as real, and the run failed. Two
different functions three lines apart, conflated.

Ground truth now records a `span` per site -- the function's real line range --
and a reported location has to fall inside it. verify_fixtures.py fails if a
span stops containing its own anchor line, so a stale hand-edit cannot
reintroduce the failure silently. LINE_WINDOW drops 30 -> 12 as the fallback for
entries carrying no span.

Line-less mentions now lean opposite ways for recall and precision, and both
directions favour not failing a run that did the work. A report naming the right
file without a line is still credited for recall. It is no longer treated as
claiming the decoy: the decoy's file in the real fixture also holds a genuine
upstream finding, so any run reporting the real one without a line number was
marked as having flagged the decoy.

Three self-tests added, all reduced from the cold run. Both fixes were
mutation-checked: reverting the span logic and reverting require_line each fail
the suite.

Also removeprefix("./") for lstrip("./"), which took a character set and ate the
leading dot of paths like .github/scripts/x.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* variant-analysis: surface loose scoring, plumb --strict-decoy, parse the workflow

summarize.py prints a `loose` column counting runs scored through score.py's
permissive fallback. A score built on it is worth less than one built on
location fields, and that was invisible.

--strict-decoy was documented in the README and implemented in score.py but
unreachable from eval.sh, which exited 2 on the unknown option. Plumbed through.
The usage header also advertised `--codebase go`, left over from the five
synthetic codebases; gradio is the only one, and passing both modes needs
quoting.

run_fixtures.sh now runs `node --check` on the workflow. It is the only
JavaScript in the repo and nothing in CI parses it, so a syntax error would
surface only inside a paid eval.sh run. Skipped, not failed, where node is
absent.

setup-gradio.sh reported "the checkout is not at $SHA" for any failed
apply --check, including a checkout at the right SHA whose patch is already
partly applied -- reachable, since the unpatched probe only looks at one of the
three files. Name both causes and the recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* variant-analysis: drop eval graders no arm can fail, correct the firing claim

The skill-not-fired graders on cases 06-07 set `arm: both`, which makes them
scored, and neither arm can fail them: the baseline arm has no plugin so Skill
never fires, and the with-plugin arm does not fire on these shapes either. The
suite's own guidance says a grader no arm can fail is worth deleting rather than
reweighting. The type: llm grader on each case carries the real check.

The "skill does not fire" limitation was overstated as a property of the skill.
A 9-run cold run across three prompt shapes locates the actual cause: it fires
2/3 on a conversational prompt and 3/3 on the description's trigger language
when there is a codebase on disk, and 0/3 on an inline candidate panel -- which
is the shape of every case in this directory. With nothing to sweep, declining
the skill is arguably correct. Giving these cases files on disk would fix the
saturated delta and the trigger rate at once; that is the highest-value change
left here and it is not a small one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* variant-analysis: describe the trigger that actually fires

The skill description was generic where the measured trigger is specific: a bug
just found in a named file, and the question of where else it occurs. It now
leads with that situation and names the bare conversational form, which is what
fired 2/3 in a cold run. The old description was diagnosed as the reason the
skill never fired; it was not, but it was still vague.

The README's entry-point table claimed the skill is "best for a narrow search
where you want a say in each generalization" and triggers on its own. Measured
on a real codebase, Claude reaches for the workflow in 4 of 5 firing runs and
the skill in 1 of 9 -- so ask for the skill by name if you want to weigh in.
Also records the size floor, and that args is a JSON object.

tests/README.md documents spans, the recall/precision asymmetry on line-less
mentions, and the loose column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Document dynamic workflow layout; variant-analysis 2.0.1

AGENTS.md described only skills/<skill>/workflows/, the prose step-by-step kind,
so the plugin-root workflows/*.js layout that ships as /<plugin>:<workflow> was
undocumented -- and variant-analysis is the first plugin in the repo to use it.
Names both, says which one a "Phase 1 / for each / repeat until" SKILL.md
belongs in, and records that ${CLAUDE_PLUGIN_ROOT} is unavailable inside a
workflow script.

Version bumped 2.0.0 -> 2.0.1 since these are behavioural changes on top of an
unmerged 2.0.0. Squash it back to 2.0.0 if you would rather ship one version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* variant-analysis: close gaps found by review of the fix PR

A line-less claim on the safe site's file fell into the gap between the strict
accusation check and the permissive examined check: it stayed out of
decoy_reported_as_real (correct -- it names no line), matched `known`
permissively so it dropped out of unreviewed_findings, and then satisfied
decoy_examined_and_ruled_out. A run was credited with correctly ruling out the
site it had just listed under Findings, and passed even under --strict-decoy.
Now surfaced as decoy_claimed_without_line, kept visible in
unreviewed_findings, and it blocks the ruled-out credit without counting as a
false positive.

The small-tree bound could drop expansion axes with no record in the artifact.
With axesPerRound=2 and one round, a 6-axis root cause left four
generalizations unattempted and only the live progress log said so; REPORT.md
was indistinguishable from an exhausted sweep. The report prompt and the return
value now carry swept/total axes and name the unswept ones.

Spans are exact def..return, which left no room for a decorator directly above
a def. RECALL_PAD=3 covers that on the recall side only; the safe site gets no
slack, since padding it walks back into the conflation the spans fixed.

verify_fixtures.py now requires a span on every entry and validates the range.
Without that, a dropped span silently reverted the grader to a proximity window
with a green suite, while ground-truth's own comment documented a guarantee that
no longer held.

source_file_count was `rg --files | wc -l`, which counts assets and fixtures.
A 25-source-file project behind 300 fixtures reported 325 and missed the floor
it was built for. The prompt and the schema now ask for source files only.

`node --check` runs against an .mjs copy. On a .js file whose first statement is
`export`, it only passes on Node ~22.7+, and lint.yml pins no Node version.

Also: pinned the extraction-mode labels as constants with a self-test, so
renaming one cannot leave summarize.py's loose column reading zero forever;
fixed a self-test fixture whose span did not contain its own anchor line, a
shape verify_fixtures.py now rejects; dropped a dead condition in parseArgs;
third-person skill description per AGENTS.md.

score.py self-test 16 -> 18 checks, summarize.py 6 -> 7. The label-rename and
span-removal mutations were both confirmed to fail the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix node check. Claude workflows have syntax like top-level returns that will trip the linter

* Add trailing newline

---------

Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
Co-authored-by: Clinton Thomas <1033162+KernelClint@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:13:41 -04:00

2.2 KiB

Variant Analysis

Find similar vulnerabilities and bugs across codebases using pattern-based analysis.

Author: Axel Mierczuk

When to Use

  • Hunt for bug variants after finding an initial vulnerability
  • Build CodeQL or Semgrep queries from a known bug pattern
  • Perform systematic code audits across large codebases
  • Create reusable patterns for recurring vulnerability classes

What It Does

A five-step process: extract the root cause, write a pattern matching only the known bug, generalize it one element at a time, triage what it finds, and report.

Each step has its own strategy reference under skills/variant-analysis/references/.

Entry Points

Use when
/variant-analysis:variants Workflow, for a large codebase or a root cause with many manifestations. Runs the steps across parallel subagents, one per expansion axis (generalizing variable names, function names, sink APIs, …), looping until the sweep stops finding anything new. Takes bug, root, lang, out as a JSON object, which Claude fills from the current context. Below ~40 source files (the primary language's, excluding vendored and fixture trees) it sweeps narrow and once, because fan-out buys nothing at that size.
The variant-analysis skill The knowledge behind the workflow, and the path for a narrow hunt you want to drive yourself — a handful of files, pasted snippets, or a candidate list to triage against a known root cause. Both fire on their own from a conversational "are there others like this?"; measured on a real codebase, Claude reaches for the workflow more often than the skill, so ask for the skill by name if you want to weigh in on each generalization.

Included

  • Tool selection guidance (ripgrep, Semgrep, CodeQL)
  • Ready-to-use CodeQL and Semgrep templates for Python, JavaScript, Java, Go, and C++
  • A report template, and the pitfalls that most often cause hunts to miss variants

Installation

/plugin install trailofbits/skills/plugins/variant-analysis
  • codeql — deep interprocedural variant analysis
  • semgrep — fast pattern matching for simpler variants
  • sarif-parsing — process variant analysis results