Fifth and last consumer swap of v8 Phase 5 step 4. The seven known-bad-identity
signatures stop living in knowledge/signatures.json and are built from the
vendored commons artifact signatures/malware-signatures.json instead.
Measured before the swap over all seven positions -- id, family, severity,
pattern, description, provenance, key order, and recompilation identity under
the engine's unconditional `i` flag: zero divergences over 56 checks, in order.
The commons copy was extracted from this repository's own file at b0de0ca and
had not drifted.
knowledge/signatures.json is REMOVED rather than left in place. Keeping it would
have left two files spelling one table with nothing gating the drift, and its
golden `file:` pin would have gone on passing while pinning bytes no scanner
reads -- a gate reporting success without running. The pin is replaced by a
walked-module anchor over SIGNATURE_RULES, which is strictly stronger: the pin
covered the bytes on disk, the walk covers what `new RegExp` made of them.
Golden diff was exactly that and nothing else: 7 ADDED, 1 REMOVED, 0 CHANGED
(102/7/5 -> 109/7/4), each added source verified equal to the recompiled commons
pattern.
compileRules() moves into the new lib module and is exported, so the built-in
ruleset and the operator's sig.custom_rules_path path keep one implementation
rather than two copies of the defaulting logic.
Coverage by construction, not by memory: the probe table in the scanner test is
asserted against the LOADED ruleset, so a rule commons adds cannot arrive
without an end-to-end probe. Mutation of the vendored JSON fires in three
directions -- under-match (xmrig alternative dropped) reddens two scanner tests
plus golden; over-match (webshell rule widened to a bare `shell`) reddens the
clean-fixture false-positive probe plus golden; reorder reddens the declared-
order test plus golden.
Loud failure is contract: an unresolvable commons writes one line to stderr
rather than silently disabling known-malware detection, and never throws.
Suite 2247 / 2241 pass / 6 skipped / 0 fail. suite-counts.json untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151x4FVg9Mn55C2LvHLpHKo
The 19 fixed credential shapes in pre-edit-secrets.mjs were regex literals;
they now come from signatures/secret-egress.json in the vendored commons via
a new scanners/lib/secret-egress.mjs. Policy-injected custom patterns (entries
20+) are unchanged and still appended by the hook.
Measured before the swap, not assumed: all 19 positions compared for order,
name, regex source and flags, plus recompilation identity, against the literal
table sliced out of the module text. Zero divergences. Commons had reported
the same result; that was their measurement, so this one was run anyway.
STATE's expectation that the golden gate would go red on both table records
and file sha256 was wrong: pre-edit-secrets.mjs is in neither PINNED_FILES nor
WALKED_MODULES, so the table had no golden coverage at all and the swap moved
nothing. Rather than leave the vendored data with only behavioural coverage,
secret-egress.mjs joins WALKED_MODULES — walked, not pinned, since it inlines
no regex of its own. Golden diff was 19 ADDED, 0 CHANGED, 0 REMOVED, each
source byte-identical to the pre-swap literal; re-blessed. suite-counts.json
untouched.
Tests: coverage is derived from the loaded table, so an entry commons adds
cannot arrive without an end-to-end probe. All 19 now block through the real
hook and are asserted by label, which also pins the ordering contract (a
Bearer-wrapped JWT must report as the header). Mutating the vendored JSON
fires in both directions plus reorder: under-match (AKIA quantifier) reddens
3 hook tests + golden; over-match (Anthropic key truncated to its prefix)
reddens the false-positive probe + golden; moving the JWT entry ahead of the
Bearer entry reddens the ordering test.
Suite 2231 tests / 2223 pass / 6 skipped. The two parallel-run failures
(pre-compact size-cap, benchmark) pass alone — the known timing flakes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGMv5ZTUhVzZtCCwRrNZG5
Third consumer swap of v8 Phase 5 step 4, after codepoints and OWASP_MAP,
and the last one with a behavioural gate behind it. The 83 regex literals
leave injection-patterns.mjs; the four arrays are now built in
scanners/lib/injection-lexicon.mjs from the vendored
lexicon/injection-lexicon.json and re-exported unchanged, so every
consumer sees the same published surface.
Behaviour-preserving by measurement, not by intent. The proven recipe ran
in order: a differential over all 83 positions (regex source, flags,
label, aliases.llm_security) found 0 divergences BEFORE anything changed;
the golden dump was then diffed post-for-post rather than read as a 9000-
character assertion, and the ONLY changed record was the sha256 of
injection-patterns.mjs itself -- 83 regex posts, 7 table records and all
counts identical. That single file digest is the diff a swap MUST produce,
so the baseline was re-blessed rather than silenced.
Two deliberate departures from the two earlier swaps:
FAILURE IS LOUD. codepoints and owasp-map fail silently on purpose: an
empty codepoint table weakens normalization, an empty OWASP map mislabels
a report. An empty injection table is different in kind -- scanForInjection
returns found:false for every input, and the UserPromptSubmit scan, the
MCP output scan and the pre-compact scan all go blind while reporting
success. That is precisely the v7.8.2 defect class, which bit this plugin
four times in one release. An unresolvable commons therefore writes one
line to stderr naming the disabled capability. It still does not throw:
hooks run per-tool-call, and a module-load throw breaks the tool call
instead of degrading the scan. The warning is suppressed for an explicit
commonsRoot, so tests and dev checkouts stay quiet and the line keeps
meaning something.
ENTRIES COMPILE DEFENSIVELY. commons is vendored data, not code. An
uncompilable pattern or unknown flag would throw inside new RegExp at
module load -- in a hook. Malformed entries are dropped instead, the same
call owasp-map.mjs makes for a non-array value.
Gates proven by mutating the vendored JSON in BOTH directions, five ways,
all firing: re-adding the script-tag tail commons dropped (golden 1,
lexicon 2, corpus 1), dropping a critical pattern (2/1/3), stripping the
`m` flag off a spoofed-header anchor (2/1), adding a pattern commons never
published (2/2/85), and removing commons outright -- which produced the
stderr line, four empty tables and 5 red rather than a green suite over
zero patterns. Lexicon restored byte-identical after each.
Full suite 2191 pass / 0 fail / 6 skipped (2184 -> 2197).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XDdiKC9ZXmcSUQ2m84s6y
Second consumer swap of step 4. OWASP_MAP stops being a hardcoded constant in
severity.mjs and is built from the vendored commons artifact
mapping/owasp-map.json by a new scanners/lib/owasp-map.mjs, re-exported from
severity.mjs so the published surface (which the golden gate walks as
severity:OWASP_MAP) is unchanged.
Scope is one of the four maps commons publishes, and the omission is measured,
not incidental. OWASP_MAP has a production consumer: owaspCategorize() reads it
as the per-scanner fallback, and that reaches real report output through
output.mjs's owasp_breakdown. OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP and
OWASP_MCP_MAP have none - every reference tree-wide is a test or a golden
artifact - so they stay source literals, the same call already made for
cyrillic_confusables in the first swap. Porting them would move data no runtime
reads into the load path.
Measured byte-likeness before the swap, all four taxonomies: same 16 prefixes,
same insertion order, same code arrays. Loadable verbatim, unlike the injection
table.
Content preservation proven the same way as the codepoint swap: the golden dump
differs in exactly one record, the sha256 of severity.mjs, which changes by
construction when a table leaves the file. All 83 regex records and all 7 table
records including severity:OWASP_MAP are byte-identical; reference-run.json
unchanged at 61/61. patterns.json re-blessed for the file digest only.
New property, not just preservation: the golden gate now pins the vendored
commons data transitively for this table too. Mutation-proven in both
directions - changing one code value and deleting a whole prefix each turn
three independent gates red (golden table digest, the new owasp-map gate by
name, and the pre-existing severity behaviour tests).
Entries are validated rather than trusted: commons is vendored data, and a
value that is not an array of strings would be spread straight into
owaspCategorize's category list, so a malformed entry is dropped. Graceful-empty
on an unresolvable commons, matching commons-loader's contract - severity.mjs is
on the import path of output.mjs and every orchestrated scanner, so a load throw
would abort a scan rather than degrade it.
Suite 2164 -> 2173, all green.
First consumer swap of step 4. ZERO_WIDTH_CHARS (5), the Unicode Tag range,
BIDI_CHARS (9) and HOMOGLYPH_MAP (28) stop being hardcoded constants in
unicode-scanner.mjs and string-utils.mjs and are built from the vendored
commons artifact codepoints/carriers.json by the new lib/codepoints.mjs.
Started here rather than at injection-patterns, which the plan ordered first:
that table is the one table that cannot be loaded verbatim (the
hybrid-xss:script-tag divergence is directional, and loading the lexicon as-is
would reverse the 90f576f recall fix). The codepoint tables were measured
byte-equal to the source constants BEFORE the swap - same members, same
values, same insertion order on HOMOGLYPH_MAP - so they load verbatim.
Proof the swap is content-preserving: the golden dump differs in exactly one
record, the sha256 of string-utils.mjs, which changes by construction when a
table leaves the file. All 83 regex records and the
table:string-utils:HOMOGLYPH_MAP digest are byte-identical, and
reference-run.json is unchanged at 61/61. patterns.json is re-blessed for the
file digest alone.
The gate is proven red-capable against the SUBJECT, both directions:
- dropping U+00AD from the vendored zero_width table fails the new
codepoints gate by name, twice;
- altering one homoglyph value reddens the golden table digest AND a
behavioural homoglyph test.
That second direction is a property the swap creates rather than preserves:
the golden gate now transitively pins the vendored commons data, where before
it pinned a source literal and a commons mutation was invisible to it.
NOT ported: commons carries cyrillic_confusables (13), and unicode-scanner.mjs
declares a set by that name - but nothing reads it. The homoglyph-mixing
detector tests isCyrillic(cp), the whole U+0400-U+04FF block. Loading it would
move dead data into the load path, so the dead const stays where it is and is
recorded instead. The recorded v8.x-B i/x drift between that set and the
lexicon class is therefore latent, not live. commons' private_use table has no
constant behind it here at all.
Graceful-empty is kept deliberately: codepoints.mjs is on string-utils'
import path and hooks import string-utils in fresh per-tool-call processes, so
a module-load throw would break the tool call rather than degrade the scan.
The loud half is the test, which asserts exact per-table counts through the
real default commons root - the same shape as the lexicon load-assertion.
Drive-by, unavoidable: the deleted JSDoc carried the "~25 entries" claim for a
28-entry table (v8.x-C). It needed a re-bless of the same file digest this
swap already forces, so it closes here at no extra cost.
Suite 2158 -> 2164, all green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7XEEFrAJsREqa9N4tpfm8
Two of the three confirmed v8.x-A evasions are closed; the third (attribute
padding) stays open by operator decision and is documented, not silently left.
Recall fix. hybrid-xss required a closing </script>, so `<script>alert(1)` and
`<script src=x.js>` both passed scanForInjection() with found: false, while the
closed form returned high. A src= tag has no body to close in the first place.
The opening tag alone is the signal, and matching it is strictly linear: one
negated-class run whose excluded character is its own terminator, so there is
no backtracking surface that could re-introduce v7.8.3 #24.
ReDoS gate. The #24 test covered six html-obfuscation patterns against the two
shapes that defect was found on. A catastrophically backtracking regex added
anywhere else in the four tables would have failed no test at all - which is
why #24 had to be found by hand. The new gate times every exported pattern and
asserts its own coverage, so it cannot be narrowed silently.
It has two classes because the blowup shapes need opposite inputs, and because
a synchronous RegExp.test() cannot be interrupted: an exponential pattern met
with a 512KB input would HANG the run rather than fail it. Class 1 uses a
28-char ambiguous-run ladder, where exponential costs ~1s and anything sane
costs microseconds; on a hit the 512KB sweeps refuse to run. Class 2 uses the
hook's real read cap to catch the polynomial #24 class. Proven to fire: an
injected exponential regex was named by label and the hang guard held.
Measured clean: worst single pattern 17ms, full large sweep 218ms.
Golden gate went red as expected and was re-blessed - diff is exactly two
lines, the regex source and the source-file digest. The 61-payload reference
run is byte-identical, so no showcase behaviour moved.
Suite 2073/2073 (was 2063; +5 recall tests, +5 gate tests).
Still open, unchanged: all 7 bounded HTML patterns evade on >256 chars of
padding, in two positions - before the attribute and inside the style value.
Wider than STATE recorded, which named only aria-label.
Advisor review on the prior commit (69cad7c) caught a real detection-kill
vulnerability before push: reading `commons.root` from the SCANNED
TARGET's .llm-security/policy.json let a hostile cloned repo redirect
llm-security's own detection corpus to an attacker-supplied (empty)
one, with graceful-empty fallback making the substitution silent — a
substitutive override, unlike sig.custom_rules_path's additive one.
Dropped the policy import entirely; commons location is this plugin's
own concern, resolved only from __dirname or an explicit test/dev
override, never from policy or the scan target.
Also fixed two issues the review surfaced:
- Default vendor path was repo-root `shared/`, which package.json's
`files` allowlist (bin/, scanners/, knowledge/) would never publish —
moved under scanners/commons/, inside the directory that actually
ships. Same defect class as 2fe2915 (green dev checkout, empty
detection tables once installed).
- Cache keyed success/failure together, so the first caller's
`fallback` shape (e.g. []) leaked to a second caller expecting a
different shape ({}) on the same missing artifact. Cache now stores
a load-failed sentinel and returns each caller's own fallback.
Loaded artifacts are also deep-frozen, since the cache hands out one
shared object by reference to every caller.
New/changed tests cover all four: a simulated hostile-target policy
file is ignored, the failure-cache no longer cross-contaminates
fallback shapes, and mutating a loaded artifact throws.
Golden baseline unchanged; full suite 2063/2063.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAYkRaBXT6tmWXTQAi1ZBg
Thin, sync-read JSON artifact loader for the future vendored
llm-security-commons subtree, modeled on signature-scanner.mjs's
loadRules()/loadCustomRules() pair: process-cached, graceful-empty
fallback on any read/parse error, and policy-extensible via a
`commons.root` policy value (mirrors sig.custom_rules_path).
Unit-tested now against a local fixture — Phase 4 (commons repo
creation, gated on the operator creating the Forgejo remote) hasn't
run yet, so the default `shared/` vendor path doesn't exist in this
checkout. That "not vendored yet" case is itself asserted: the loader
must degrade to the caller's fallback, not crash.
Not wired to any consumer yet (that's Phase 5 step 4, table-by-table
behind the golden gate). No CLI/hook/scanner-visible behaviour exists
to document. Golden baseline unchanged; full suite 2063/2063 (2053 +
10 new).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAYkRaBXT6tmWXTQAi1ZBg
Records the reference artifacts Phase 5 swaps will be measured against, and
the gate that reads them. No extraction yet: this is the "before" picture,
and it had to land first or every later comparison would be confounded.
Typed test/ rather than feat/ deliberately - the only production change is
one widened export; the rest is gate, artifacts and generator.
Written failing-first (7 red on missing artifacts), then generated.
Three layers, because the plan's "assert .source/.flags of every regex" is
necessary but not sufficient:
1. regex records - .source/.flags off the COMPILED object. After a swap a
pattern is new RegExp(jsonString, flags), so the plan's named hazard
(JSON backslash-doubling on 83+18 regexes) is visible here and nowhere
else. Source-text comparison cannot see it.
2. table records - key/value digests. Most of what Phase 4 moves is not a
regex at all: HOMOGLYPH_MAP (x3, AS-IS), the typosquat tokens and the
four OWASP maps are char->char and string->string data. A regex-only
dump is blind to a broken homoglyph swap, i.e. to the bulk of the
payload. Operator decision: widen the dump.
3. file records - sha256 of the five moving-set sources. This dissolves
STATE's open question (how to enumerate every regex): it is complete by
construction, covering inline regexes in function bodies that no export
walk reaches, with no JS parser in a zero-dep repo. A lexical count
would have pinned a lie - severity.mjs scores 4 "regexes" that way and
exports none.
Both layers were proven to fire, not assumed to: mutating one HOMOGLYPH_MAP
entry reddens the table layer, and widening an inline regex inside
decodeHexEscapes (unreachable by any export walk) reddens the file layer.
HOMOGLYPH_MAP is now exported from string-utils.mjs. That export is a source
change the plan already flags as a surface hazard ("private tables become
loaded"), so it is pre-paid here rather than confounding the before/after.
Reference run: the 61 showcase payloads through the real hook entry points,
sequentially - array order is semantic and the plan forbids key-sorting, so
concurrency is removed rather than sorted away. 61/61 match expectation,
which also settles the plan's open assumption that payloads.json expectations
match current behaviour. Coverage is recorded, not assumed: 47/83 patterns,
with the other 36 listed by key so the gate never implies coverage it lacks.
Suite counts are per-file, each file run alone - a total is unattributable,
and the three known timing-sensitive files flake only under concurrency.
2045 pass / 0 fail across 91 files, matching the pre-existing count exactly.
The gate's own file is excluded (it reads the artifact the run produces) and
that exclusion is named in the artifact.
Suite: 2053/2053, 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
Closes Phase 3 (B11) of the v8.0.0 plan. Three parts, all with the failing
test written first.
riskScoreV1 removed. scanners/lib/severity.mjs drops riskScoreV1() and its
SEVERITY_WEIGHTS_V1 table - @deprecated since v7.0.0, kept for diff/comparison,
zero callers in code or tests (re-verified, not taken from the plan). The v1
weights are recorded in CHANGELOG so an old score stays re-derivable. riskScore
(v2) is untouched; a test pins that one critical still lands in the 70-95 tier
and that 50 lows score below it, which is exactly the case v1 collapsed to 100.
Posture category 12 no longer keys off an identifier name. The check was
/TRIFECTA_MODE/i over the session-guard source, which measured what a constant
was CALLED rather than whether enforcement was configurable. With the env-var
gone, that regex would have dropped every correctly-migrated project from PASS
to PARTIAL - the gate punishing the migration it exists to encourage. It now
matches getPolicyValue('trifecta', 'mode', ...) and still accepts a pre-v8
vendored guard reading the old env-var, because a third-party project carries
its own hook copy and is equally configurable either way; the evidence line
says which of the two was found. The PARTIAL finding recommended setting an
env-var that v8 ignores; it now names the policy key. The grade-a fixture hook
moves to the policy-era form.
Two never-implemented env-vars deleted from the docs. LLM_SECURITY_SCR_OFFLINE
(ci-cd-guide) and LLM_SECURITY_OFFLINE (supply-chain-attack example) were
documented as OSV.dev / npm-audit kill-switches. No code has ever read either -
verified by grep across scanners, hooks and scripts, which finds them only in
markdown. A promised kill-switch that does nothing is worse than a documented
absence: it is trusted precisely when the run is meant to be air-gapped. The
docs now say there is none and that egress must be blocked at the network
layer. The LLM_SECURITY_AUDIT_* wildcard is narrowed to the one real key.
Docs. Migration section in README + CHANGELOG with the env-var -> policy-key
table, the detection commands (env + shell rc + .envrc + workflows), and the
explicit warning that a removed variable is now INERT rather than an error -
which is the failure mode that loses a project its configuration silently. The
hardening-guide env table splits into surviving vars and a removed-vars
migration table; its "promote to block" runbook named two variables that no
longer exist. Also swept: CLAUDE.md hook table, scanner-reference, ci-cd-guide,
both lethal-trifecta example docs, mitigation-matrix, injection-research.
Test counts in README/CLAUDE.md synced 2034 -> 2045.
Suite 2045 tests, 0 fail (2039 + 4 posture-trifecta + 2 riskScoreV1). The two
known parallel-load flakes did not recur this run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
BREAKING CHANGE: the four LLM_SECURITY_* configuration env-vars deprecated in
v7.3.0 are removed. .llm-security/policy.json is now the only source:
LLM_SECURITY_INJECTION_MODE -> injection.mode
LLM_SECURITY_TRIFECTA_MODE -> trifecta.mode
LLM_SECURITY_ESCALATION_WINDOW -> trifecta.escalation_window
LLM_SECURITY_AUDIT_LOG -> audit.log_path
LLM_SECURITY_DEPRECATION_QUIET -> dies with the mechanism it silenced
Setting a removed var is now inert - it does not warn, and it does not
configure. Env-vars with no policy equivalent (PRECOMPACT_MODE,
PRECOMPACT_MAX_BYTES, UPDATE_CHECK, MCP_CACHE_FILE, IDE_ROOTS) are unaffected.
getPolicyValueWithEnvWarn and its one-shot stderr warning are deleted from
policy-loader.mjs, along with the module-scoped warned-var Set. The four call
sites collapse to getPolicyValue. getPolicyValue's JSDoc claimed "environment
variables ALWAYS take precedence" - it never read env itself, so that line
described the shim, and it is corrected rather than deleted.
User-facing hook strings that advertised a removed var as the escape hatch now
name the policy key instead: the inject-scan block reason, its warn-mode note,
both escalation-window advisories, and the trifecta block message. A blocked
user following the old text would have set a var that does nothing.
Tests. tests/lib/v8-env-removal.test.mjs is the regression gate and was written
failing first (8 of 12 red before the change). It pins the NEGATIVE - setting a
removed var does not alter the outcome - because that is the half that rots
silently: a re-introduced process.env read would leave every migrated positive
test green, since those configure through policy.json and never set the var at
all. One assertion walks hooks/scripts and scanners for `process.env.<removed>`
so the re-introduction is caught structurally, not only behaviourally.
The 44 env-driven test occurrences (18 inject-scan, 13+4 session-guard, 9
audit-trail) migrate to a throwaway .llm-security/policy.json via a new
runHookWithPolicy helper in hook-helper.mjs; audit-trail runs in-process, so it
supplies the same policy through CLAUDE_PROJECT_ROOT. The D3 mechanism tests in
policy-loader.test.mjs are deleted with the mechanism.
Suite 2039 tests, 2037 pass (+12 gate, -7 D3 mechanism). The 2 failures are the
known parallel-load timing flakes (pre-compact-scan size-cap,
pre-install-supply-chain F-3); both green when run isolated.
Remaining in Phase 3: posture-scanner TRIFECTA_MODE heuristic, riskScoreV1
removal, ghost-var cleanup, docs + migration note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BB4vXvwvtW4dxbPRd6vsez
Ran repo-standard v0.1.1 against this repo (class: plugin, trait: security).
6 ERROR, 4 WARN, 1 SKIP recorded before any edit; now 0 ERROR, 12 checks pass.
BROKEN
- INSTALL-NO-CLI: added `claude plugin install llm-security@ktg-plugin-marketplace`
beside the existing `marketplace add` line. The settings.json `enabledPlugins`
block stays — it is a legitimate second form, just not a CLI command.
- LINK-OUTSIDE-REPO (README:7, reported by catalog): the disclosure link pointed
at `../../README.md#ai-generated-code-disclosure`, relative to the pre-split
monorepo and anchored at a heading that never existed. Replaced with the inline
text the polyrepo migration's step 5 was meant to write.
MISSING
- `## Non-goals`: added as its own heading over the existing out-of-scope table
inside `## Project scope`. The `## Project scope` heading is kept because
SECURITY.md and CONTRIBUTING.md reference it by name.
- `## Changelog`: promoted from the trailing "Full history in CHANGELOG.md" line.
- `## Known limitations` (required by the `security` trait): renamed from
`## What this plugin does NOT cover` — same table, contract heading.
WEAKENING
- README-DESC: opening line now matches the forge description verbatim, so
description == catalog == README holds. This replaced the tagline
"Automated defense and advisory analysis for the agentic AI attack surface."
- HEADING-LEVEL: `### Install` promoted to `## Install`; `## Quick Start` split
into `## Requirements` / `## Install` / `## First scan`.
- BADGE-STATIC-CLAIM: dropped the static `tests-2034` badge. No runner exists on
this forge, so the badge asserted a run nothing performs. Replaced under
`## Self-scan` with the command that runs the suite from a clean clone, and
the plain statement that nothing runs it automatically.
- LINK-NON-REPO x2: `open/claude-code-llm-security` (pre-split name) in
V3-ANNOUNCEMENT.md updated to `open/llm-security`. Same dead name found in
sarif-formatter.mjs TOOL_URI, which ships into SARIF output consumed by other
tools, so it is corrected too.
Left standing, deliberately:
- WARN README-H1 `# LLM Security Plugin for Claude Code` != `# llm-security`.
The gate defers this to the operator, and the description thread it protects
is now carried by the opening line instead.
Suite 2034/2034.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016h37aUdBLVDT9xrvG9osA9
Extends tests/lib/doc-consistency.test.mjs with 15 cases that derive every
inventory count from source instead of trusting prose. Each count has one
stated derivation; a doc surface that disagrees now fails the suite.
Counts corrected (all were wrong before the test existed):
- orchestrated scanners: docs said 10 (README, ci-cd-guide), CLAUDE.md said 12,
the synthesizer agent said 9 — scan-orchestrator registers 14
- total scanners: README badge + 3 prose sites said 23; the counting rule in
docs/scanner-reference.md (14 orchestrated + 8 standalone) yields 22
- knowledge files: README badge + prose said 22; knowledge/ holds 23
- output.mjs finding() prefix JSDoc listed 10 of the 17 prefixes actually
passed to it (missing IDE, MCI, MEM, PST, SCR, TFA, WFL)
- norwegian-context.md said "8 hooks, 10 scanners" -> 9 and 14
- ci-cd-guide "what gets scanned" table listed 10 of 14 rows; adds workflow,
trigger abuse, signature, AST taint
Two plan items changed after verifying against ground truth:
- CLAUDE.md's synthesizer "(12 scanners)" was not a deliberate subset; the
agent file itself claimed 9. Both bumped to 14.
- compliance-mapping.md's "13 posture categories" is substantively correct —
its matrix has exactly 13 data rows, and categories 14-16 are governance
consumers of the file, not rows in it. The planned 13->16 bump would have
made the document false. Wording clarified to "code-level" instead, and the
test now pins row count against the stated claim.
Framework currency (both verified against primary reporting):
- EU AI Act: Digital Omnibus (EP 2026-06-16, Council 2026-06-29) deferred the
high-risk obligations behind Art. 9/15 to 2027-12-02 (Annex III) and
2028-08-02 (Annex I); transparency still applies from 2026-08-02
- OWASP Agentic AI Top 10 labelled as the 2026 edition
Also: CLAUDE.md Distribution section rewritten monorepo -> polyrepo (each
plugin is its own repo; the catalog pins url + ref per plugin), and
current-state test counts synced 2013 -> 2034. Release-note paragraphs keep
their historical numbers.
No scanner, hook, or command behaviour changes. Suite 2034/2034.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wt4YQGoXwRja5K2Zmv8RZE
Three TDD-first fixes surviving the B8 roadmap bucket (v8.0.0-plan.local.md
Phase 1, items 1-3; item 4 JAR hardening scoped out at review):
- supply-chain-recheck.mjs parseYarnLock: ported the hook's per-entry parser
(pre-install-supply-chain.mjs) so Berry's `version: x` format (unquoted) is
recognized alongside Classic's `version "x"` — Berry lockfiles previously
yielded zero deps, silently missing pinned compromised packages.
- supply-chain-recheck.mjs parsePackageLock: lockfileVersion-1 fallback now
recurses nested `dependencies`, mirroring the hook's walk() — a transitive,
non-hoisted compromised copy below the top level was previously invisible.
- content-extractor.mjs stripInjection: attribution moved from a global
`Set<label>` to `Set<label::lineIndex>`. The old check silenced the
unstripped flag for ANY occurrence of a label once ANY occurrence had been
line-redacted, so a second, cross-line-only encoded occurrence of the same
label survived into sanitized output without being flagged.
Full suite 2019/2019 (one known-flaky timing test confirmed green in isolation).
#20 git-forensics' reflog force-push detector had a redundant bare 'reset' term subsuming 'reset:', so any commit subject containing 'reset' tripped it; removed. #22 diff-engine's per-current moved-fallback greedily consumed a baseline candidate a later byte-exact match needed (mislabeling unchanged as new/moved on duplicate fingerprints); a global exact pass now runs before the moved-fallback. #54 memory-poisoning double-reported a 64+ char hex token as both base64 and hex; the base64 check now skips pure-hex tokens.
#56 SARIF output hardcoded driver.version 6.0.0 because the orchestrator called toSARIF() without a version; it now passes the real plugin version from package.json. #50 the VS Code known-malicious blocklist was empty with no explanation (unlike the JetBrains file's 'empty by design' note); added a matching blocklist_note. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#21 the bash normalizer decoded only \xHH, leaving ANSI-C octal/\u/\U forms literal so canonical rm/curl never surfaced; now decodes all three. #23 file-discovery keyed on extname so .env.local/.env.example (extname .local/.example) were silently skipped; now matches multi-part suffixes. #42 a legitimate leading UTF-8 BOM was flagged HIGH (and the tool's own auto-cleaner refused to strip it); pos-0 BOM now excepted. #52 collapseLetterSpacing used a literal space, letting multi-space/tab spacing evade; now [ \t]+. #55 redact(_,60,0) did slice(-0) and leaked the whole unredacted URL; showEnd===0 now means no tail.
#30 embedded base64 (const x = "<base64>") never satisfied the whole-string decode, so the SIG identity engine never saw it; added decodeEmbeddedBase64 as an OPT-IN param on normalizeForScan (default off — appending a decoded copy would double-count per-match findings, e.g. content-extractor's injection scan) and enabled it only in signature-scanner, which dedups variants. #36 signature-scanner ignored the documented sig.custom_rules_path policy option; now loads+merges custom rules through the same family filter. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#33 the frontmatter parser collected a block-scalar body (description: |) but did not skip it, so an indented name:/allowed_tools: inside the body re-matched as a top-level key and overrode the real values TRG-shadow and permission checks depend on; the parser now consumes block-scalar bodies as opaque content. #32 block-scalar headers carrying indentation/chomping indicators (|2, >-, |-2) were not recognized, so their bodies never reached the run: injection sink; now matched via a proper indicator/chomping regex.
#43 the B4 actor auth-bypass detector inspected only braced ${{ }} expressions, missing the canonical bare 'if: github.actor == ...' form (Synacktiv Dependabot-spoof false negative); bare if: expressions now emit a synthetic event the detector reads. Suite 2004/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#24 the HTML-obfuscation injection patterns had overlapping unbounded runs plus a required closing quote, so a non-closing input backtracked O(N^2) (~28.7s at the 512KB cap); quantifiers bounded, pathological input now 4ms. #53 mcp-live-inspect buffered MCP-server stdout via readline with no cap, so a hostile stdio server could exhaust memory / throw an uncaught RangeError; replaced with manual line buffering capped at 4MB that rejects pending RPCs and destroys stdout. #31 vsix-fetch's same-host redirect follower had no depth cap (loop hang); added depth>=5 cap mirroring the sibling fetcher.
#25/#51 mcp-description-cache and skill-registry wrote JSON via bare writeFileSync (non-atomic: concurrent load-modify-save loses updates, a torn read silently yields an empty registry); both now write a temp file then renameSync. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#38 toxic-flow matched trifecta-leg keywords with bare includes(), so substrings ('url' in 'curl', 'key' in 'monkey', 'auth' in 'author') fabricated CRITICAL trifectas on benign components; now word-boundary matched. #40 TRG-broad fired HIGH on a bare any/all/every anywhere ('fix any lint errors'); the universal-claim regex now requires genuine universal phrasing. #41 TRG-baiting substring-matched ('any file' in 'many files'); now boundary-anchored. #39 the broad-name list missed multi-char generic names (helper/assistant/auto/general/agent/tool); widened coherently so it does not reintroduce #40. #57 the '(recovered from obfuscation)' label compared raw against a lowercased normal form, firing on any uppercase char; now gated on an explicit decode-changed flag.
#26 (same file) getPolicyValue used 'key in sectionObj' with no type guard, so a scalar section override in policy.json (e.g. {"injection":"block"}) threw an uncaught TypeError; now guarded to fall back to the default. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
#18 dep-auditor called 'pip audit' (no such subcommand) so Python CVE detection was a permanent silent no-op; now spawns 'pip-audit' with an argv array. #19 it audited the scanner HOST's env with results mislabelled to the target's requirements.txt; now audits the target via -r and skips cleanly when absent.
#14 the offline npm blocklist was skipped for bare/range/tag installs because it used the (null) parsed-spec version; now re-checks the resolved version before the OSV network path. #15 non-hoisted nested lockfile keys derived the wrong package name (leading-only node_modules/ strip); now strips to the last segment. #48 lockfileVersion-1 nested dependency trees are now walked recursively.
#16/#17 the yarn.lock matcher paired two unassociated whole-file substrings with an unanchored pkg@ (false BLOCK of a legit package) and only matched Yarn Classic quoted versions (Berry known-malware allowed); rewritten as a per-entry parser that associates version to its own entry, anchors the name, and matches both Classic and Berry. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
py-ast-taint.py Assign handler never removed a name from the tainted set on reassignment, so a source-then-constant/sanitizer rebind (g=os.getenv(); g='safe'; os.system(g), or x=shlex.quote(x)) kept stale taint and fired false AST-CMD-EXEC findings. Assign now clears taint for target names when the RHS is not a source. No cross-expression propagation added — the f-string/concat/alias recall gap (#27) stays deferred to v8.
#28: added fixtures+assertions locking the tainted subprocess/os.system AST-CMD-EXEC sink and the open(...,'w') AST-FILE-WRITE sink. Suite 1931/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
decodeEntities guarded parseInt with Number.isFinite, which bounds
nothing: any numeric character reference above 0x10FFFF produced a
finite integer that String.fromCodePoint rejects with RangeError.
Correction to the review's framing: this does NOT break parsePluginXml's
no-throw contract. The per-field safe() wrapper catches it. The actual
defect is quieter — the affected field is discarded and replaced with
'', with only a warning. A plugin whose <name> carries one out-of-range
reference parses as name: "" while pluginId and every other field
survive, so name-based checks (JetBrains typosquat detection against the
top-plugin list) run against an empty string.
Severity is below the HIGH it was filed as: a document containing a code
point above 0x10FFFF is not well-formed XML, so IntelliJ would reject
such a plugin too — the evasion yields a plugin that does not load. The
fix is still correct and one line of logic.
- isDecodableCodePoint(): integer, >= 0, <= 0x10FFFF.
- Undecodable references are now left literal, matching how lenient
parsers treat unrecognised entities and how this same function already
treats unknown named entities.
Regression test drives parsePluginXml with hex and decimal references
just past and far past the maximum, asserts the no-throw contract still
holds, and pins that valid references, the maximum valid code point, and
named entities still decode.
npm test: 1901/1901 green (1890 + 11 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
stripInjection is the remote-scan indirection layer: its `sanitized`
output is what an LLM agent actually reads, verbatim, via
sanitized_content in the evidence package. It scanned two variants of
each file — the raw text and normalizeForScan(text) — but removed
matches with `sanitized.replace(match[0], ...)` against the RAW text
only.
For a match found in the decoded variant, match[0] IS the decoded
string, which by construction does not occur in the raw text. The
replace was therefore a silent no-op: the finding was reported while the
encoded payload was passed to the agent untouched. Every obfuscation the
normalizer exists to defeat — HTML entities, URL encoding, \u escapes,
hex, base64, letter-spacing, Unicode tags — reached the agent intact.
The worst case is the intended one: detection said "critical injection
found" and shipped the injection along with the verdict.
- Pass 1 redacts the whole source LINE whose own normalized form carries
the pattern. Line granularity is deliberate: decoding is not
length-preserving, so decoded match offsets cannot be mapped back onto
the original text.
- Pass 2 keeps the existing literal replacement and finding collection.
- Residual gap, made explicit rather than silent: a payload encoded
across MULTIPLE lines matches whole-text normalization but no single
line, so it cannot be attributed. Those findings now carry
`unstripped: true`. Whole-file redaction was considered and rejected —
normalizeForScan base64-decodes any long blob, so a benign asset could
blank an entire file's evidence.
- stripInjection exported via __testing, and main() is now behind the
standard isMain guard (copied from dashboard-aggregator.mjs) so
importing the module does not execute the CLI. CLI verified unchanged
against the evil-project-health fixture: 7 files, 6 injection
findings, risk_level critical.
This boundary had no direct test coverage before this commit.
npm test: 1890/1890 green (1884 + 6 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
The two manifest parsers disagree on how they signal failure:
parseVSCodeExtension returns a bare null, parseIntelliJPlugin returns a
TRUTHY { manifest: null, warnings }. scanOneExtension guarded only the
bare-null form, so every JetBrains failure path — lib/ missing, lib/ not
a directory, lib/ unreadable, no jars in lib/, no jar extractable — fell
through the guard and dereferenced `manifest.hasSignature`.
The resulting TypeError propagated out of scanOneExtension into
mapConcurrent, which awaited fn() with no per-item try/catch, so
Promise.all rejected and the ENTIRE ide-scan aborted. One malformed
plugin directory on disk was enough to take down the scan of every
other installed extension — including, in an audit context, a plugin
that is malformed precisely because it is hostile.
- scanOneExtension: guard is now `!parsed || !parsed.manifest`, and the
parser's own warnings are carried into the result so the reason for
the skip survives instead of being replaced by a generic message.
- unscannableExtension(): the result envelope is extracted so the early
return and the isolation belt below produce the same shape.
- Belt (defense-in-depth): the scanOneExtension call site catches per
extension and yields an unscannable result. mapConcurrent stays
generic — the isolation lives at the call site, not in the helper.
Regression test drives scanOneExtension across all four JetBrains parse
failure paths plus a no-invented-findings assertion.
npm test: 1884/1884 green (1879 + 5 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Rule 4 in isFalsePositive ("test/fixture files intentionally contain
example secrets") matched /(test|spec|fixture|mock|__test__|__spec__)/i
against absPath — the ABSOLUTE path. Any directory name above the scan
root therefore silenced every entropy finding in the entire target: a
repo cloned to a CI workspace, checked out under a parent folder named
`testing`, or scanned from any path with one of those substrings
reported zero secrets and still exited status 'ok'. The failure is
silent — indistinguishable from a clean scan.
- isFalsePositive takes relPath and rule 4 keys off it. relPath was
already computed and passed down to scanFileContent; only the
suppression check was reading the wrong one.
- classifyFileContext keeps using absPath: it reads the basename
extension only, so directory names above the root cannot affect it.
- Rules 1-3 and 5-13 are untouched.
Regression test drives scan() end-to-end against temp roots named
llmsec-test-*, llmsec-spec-*, llmsec-fixture-* and llmsec-mock-*, with a
neutral-root control proving the payload is detectable, plus two
guards that genuine suppression still works (a *.test.mjs file and a
file under a relative tests/ directory).
npm test: 1879/1879 green (1872 + 7 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
validateContent() syntax-checked .mjs/.js/.cjs candidates via
execSync(`node --check "${tmpPath}"`), where tmpPath derives from the
untrusted scanned-repo FILENAME. The F-2 guard checks path containment
but never quotes or strips shell metacharacters, so a file named
`x";<cmd>;".mjs` in a scanned repo turned `/security clean` (live is the
documented default) into arbitrary command execution. Verified with a
live PoC before the fix.
- validateContent: spawnSync('node', ['--check', tmpPath]) — no shell.
- CLI orchestrator fallback: same treatment (argv array, explicit
status/error handling instead of execSync's throw-on-nonzero).
- Belt (defense-in-depth): applyFixes now refuses findings whose `file`
carries shell or control metacharacters, reported as skipped.
- Regression tests cover both layers separately, so the belt cannot mask
a re-introduced shell in the sink.
node --test: 1865/1865 green (1863 + 2 new).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
Session B of the security-fix track. Both sinks trusted untrusted strings at a
filesystem/subprocess boundary; same fix family as F-1.
F-2 (HIGH, arbitrary file write) — scanners/auto-cleaner.mjs: applyFixes() did
resolve(targetPath, f.file) with no containment, then wrote the cleaned content
back. f.file is untrusted (scanned-repo filenames, or a fully attacker-chosen
--findings file), so file: "../../.claude/settings.json" let the cleaner modify
files OUTSIDE the scanned tree. Add a prefix-containment check before grouping:
absPath must equal targetPath or start with targetPath + sep, else the finding
is refused and reported as skipped. (Documented residual gap: prefix containment
does not stop a symlink inside the tree pointing out — noted inline.)
F-3 (HIGH, command injection, pre-confirmation) — hooks/scripts/
pre-install-supply-chain.mjs: inspectNpmPackage ran execSafe(`npm view ${spec}
--json`), a shell string. spec derives from package tokens parsed out of the
scanned Bash command, so a metachar-bearing token reached the shell on
PreToolUse(Bash) — BEFORE the install, so it ran even if the user then denied
the command. Switch to spawnSync('npm', ['view', spec, '--json']) (no shell);
spec is passed as one argv element. The static `npm audit --json` execSafe call
is left as-is (no interpolation).
TDD (repro -> red -> green):
- tests/scanners/auto-cleaner-traversal.test.mjs: a "../secret.txt" finding must
not rewrite the outside file; contained files still get cleaned.
- tests/hooks/supply-chain-injection.test.mjs: `npm install $(>/abs/PWNED)`
(redirect-only $(...) survives normalizeBashExpansion + the whitespace split)
must not create the sentinel.
Closing gates: full node --test suite 1863/0 (was 1860; +3); gitleaks clean;
F-1 regression re-run GREEN (sink still closed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
git-forensics ran execSync(`git ${cmd}`), interpolating attacker-controlled
filenames from the SCANNED repo (git ls-files / git log --name-only) into a
shell string. A hostile repo containing a file named `commands/$(touch X).md`
achieved zero-interaction RCE on `/security scan <url>`: gitScan is in the
default scanner array and runs OUTSIDE the git-clone OS sandbox (which wraps
only the clone), so it executed unsandboxed on all platforms.
Convert the git() helper to spawnSync('git', [...args]) with no shell; every
call site now passes discrete tokens (shell quoting removed — git does its own
pathspec globbing). The helper throws on non-zero exit, preserving existing
per-category/per-file try/catch semantics.
TDD: adds a failing-first regression (tests/scanners/git-injection.test.mjs)
that builds a hostile-filename fixture repo and asserts the injected command
never runs. RED against execSync, GREEN after the fix.
Also removes two stray committed root artifacts (F-5/F-6): `--json`
(0-byte redirect husk) and .orphaned_at.
Closing gates: full node --test suite 1860/0; gitleaks clean. F-2/F-3 follow
in Session B.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
Bumps from v7.3.1 to v7.4.0. Purely additive surface — no scanner
or hook behavior changes, no breaking changes.
Headline content (already merged on main since v7.3.1):
- examples/ utvidelse — seven runnable demonstration walkthroughs
shipped over three sessions (sesjon 1 pre-existing
prompt-injection-showcase + lethal-trifecta-walkthrough,
mcp-rug-pull, supply-chain-attack, poisoned-claude-md,
bash-evasion-gallery, toxic-agent-demo, pre-compact-poisoning).
Each is self-contained: README + fixture + run-script +
expected-findings testable contract. State-isolation pattern
(PID-suffixed JSONL or env-overrides like
LLM_SECURITY_MCP_CACHE_FILE) keeps the user's real cache and
/tmp state untouched.
- tests/e2e/ — three new suites totalling 45 tests:
attack-chain.test.mjs (17), multi-session.test.mjs (9),
scan-pipeline.test.mjs (19). Test count 1777 to 1822. These
exercise the framework as a coordinated system rather than as
isolated unit-tests.
Version sync (8 files):
- package.json
- .claude-plugin/plugin.json
- CLAUDE.md (header)
- README.md (badge + Recent versions tabellen new row)
- CHANGELOG.md (Unreleased to [7.4.0] - 2026-05-05 with summary)
- scanners/dashboard-aggregator.mjs VERSION constant
- scanners/ide-extension-scanner.mjs VERSION constant
- scanners/posture-scanner.mjs VERSION constant
Stabilization-stance unchanged. v8.0.0 remains the planned
deprecation-cleanup release. v7.x continues as the stable line.
Tests: 1822/1822 grønne lokalt etter bump.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
No behavior changes. Sets the public stance, tightens documentation, and
removes coherence drift so anyone forking or downloading the plugin gets
a consistent starting point.
Added:
- CONTRIBUTING.md — public fork-and-own guide. Why PRs are not accepted,
how to fork well, what is welcome via issues.
- README "Project scope" section — out-of-scope table naming what is
fork-and-own territory (web dashboard, fleet policy, runtime firewall,
IDE LSP, compliance pack, ticketing, multi-tenancy, ML detectors,
marketplace UI, SSO/SCIM/RBAC) with commercial alternatives.
- package.json: bugs.url, CONTRIBUTING/SECURITY/CHANGELOG in files
whitelist for npm publishing.
Changed:
- SECURITY.md rewritten. Supported-versions table from stale 5.1.x to
current reality (7.3.x active, 7.0-7.2 best-effort, <7.0 EOL).
Best-effort solo response timeline. Scope expanded to bin/.
- Scanner VERSION constants synced to plugin version. Was 6.0.0 in
dashboard-aggregator and posture-scanner.
- package.json repository.url corrected from fromaitochitta/ to open/.
- README "Feedback & contributing" links to CONTRIBUTING.md.
Fixed:
- pre-compact-scan size-cap timing test ceiling raised 500ms -> 1000ms.
Was a flake on Intel Mac and CI under load. Design target unchanged
(<500ms, documented in CLAUDE.md).
Notes:
- First patch on the stabilization line (post-2026-05-01).
- Wave E attack-simulator scenarios deferred indefinitely; coverage
remains at 72.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Batch C release. Closes 12 implementation tasks (E3, E8-E14, 8.4, 8.6,
8.7, 8.10) across four execution waves: A (bash + decoder), B (supply
chain + workflow scanner), C (MCP cumulative drift), D (code quality).
Wave E (9 new attack-simulator scenarios for the new defenses) deferred
to v7.3.1 — defenses are unit-tested per wave; the deferred work adds
attack-simulator regression coverage on top, not the primary safety net.
Tests: 1665+ → 1777 (Wave A-D cumulative, +112).
Version sync targets touched:
- package.json
- .claude-plugin/plugin.json
- CLAUDE.md (header)
- README.md (badge + new release-history row)
- scanners/ide-extension-scanner.mjs (VERSION constant)
- ../../README.md (marketplace root plugin entry)
- CHANGELOG.md (new [7.3.0] section per Keep a Changelog, all 12 task
IDs covered individually under Added/Changed/Documentation/Tests/Notes)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wave C step C3: closes E14 with the user-facing reset command.
After a legitimate MCP server upgrade the sticky baseline (added in C1)
becomes a stale "what the tool used to say" anchor and every subsequent
post-mcp-verify advisory will re-flag the change. /security mcp-baseline-reset
lets the user acknowledge the upgrade so the next call seeds a fresh
baseline.
New files:
- scanners/mcp-baseline-reset.mjs — small CLI wrapper around clearBaseline /
listBaselines. Modes: --list (read-only), --target <name>, no-args (all).
Outputs JSON summary on stdout. Exit 0 always (idempotent).
- commands/mcp-baseline-reset.md — dispatcher following mcp-inspect.md
shape. Frontmatter: name=security:mcp-baseline-reset, sonnet model,
Read/Bash/AskUserQuestion tools. 4-step body (list -> confirm scope
-> execute -> confirm result).
- tests/scanners/mcp-baseline-reset.test.mjs — 10 CLI tests across
--list, --target, clear-all, idempotency, history preservation, and
bare-positional sugar.
Updated:
- commands/security.md — new row in commands table after mcp-inspect.
- CLAUDE.md — new commands-table row + new v7.3.0 narrative section
describing the baseline schema, cumulative-drift detection, reset
semantics, and the LLM_SECURITY_MCP_CACHE_FILE override.
- Plugin README.md — new MCP-baseline-reset row in commands table,
scanner count 12 standalone -> 13 standalone, new "MCP Description
Drift (E14, v7.3.0)" subsection explaining the sticky baseline,
cumulative threshold, reset semantics, and env-var override.
- Root marketplace README.md — scanner count 22 -> 23 (10 orchestrated +
13 standalone), command count 19 -> 20, test count 1511 -> 1768.
Wave C complete: 1738 -> 1768 tests (+30 across C1/C2/C3). Per plan,
Wave C does NOT bump the plugin version — that lands at the wave-bundle
release. The advisory text in post-mcp-verify already references the
new command path so the user has a ready remediation step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wave C step C1: extend the MCP description cache schema with a sticky
baseline slot per tool and a rolling history array (last 10 drift events).
Cumulative drift = levenshtein(current, baseline) / max(|current|, |baseline|);
emits a separate signal when ratio >= mcp.cumulative_drift_threshold
(default 0.25). Per-update drift logic and threshold unchanged.
- loadCache(): TTL purge now skips entries with a baseline, preserving
cumulative-drift detection across the 7-day window. v7.2.0 entries
(no history field) are migrated on read by seeding baseline from the
current description and adding an empty history array. Entries with
history but no baseline (post-clearBaseline) are NOT re-seeded.
- checkDescriptionDrift(): when an entry exists with history but no
baseline (i.e. baseline was cleared), the next call re-seeds baseline
from the incoming description so the legitimate next version becomes
the new baseline.
- clearBaseline(toolName?): removes baseline for one tool or all tools.
Preserves description / firstSeen / lastSeen / history.
- listBaselines(): read-only listing for the upcoming reset CLI.
- LLM_SECURITY_MCP_CACHE_FILE env var override for end-to-end testing.
- New policy key mcp.cumulative_drift_threshold (default 0.25).
Tests: 23 new unit tests; existing 10 still pass.
Docs deferred: CLAUDE.md update lands in C3 alongside the new
/security mcp-baseline-reset command. C2 adds the hooks-table footer
note. Combined wave docs match plan §"Wave C — Touch" list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes E11. Three new pieces, plus integration:
1. Re-interpolation detector (Appsmith GHSL-2024-277 stealth pattern).
The scanner now collects env: bindings (key -> source-expression
text) by walking parsed events whose parentChain includes 'env',
then for each `${{ env.<KEY> }}` inside run:, re-injects MEDIUM
if the binding source matches the 23-field blacklist. This
catches the pattern where developers apply env-indirection but
then re-interpolate the env var in run:, which cancels the
mitigation (template substitution happens before shell parsing).
2. Auth-bypass category (Synacktiv 2023 Dependabot spoofing).
Detects `if: ${{ github.actor == 'dependabot[bot]' }}` and
variants. MEDIUM, owasp: 'LLM06' (Excessive Agency). Distinct
from injection — same expression syntax, different threat class.
Recommendation steers users to `github.event.pull_request.user.login`.
3. severity.mjs OWASP map registration. WFL prefix added to all
four maps:
- OWASP_MAP['WFL'] = ['LLM02', 'LLM06']
- OWASP_AGENTIC_MAP['WFL'] = ['ASI04']
- OWASP_SKILLS_MAP['WFL'] = []
- OWASP_MCP_MAP['WFL'] = []
Empty arrays for skills/MCP are explicit, not omitted — keeps
`Object.keys(OWASP_MAP)` symmetric across maps.
4. scan-orchestrator.mjs registration. workflowScan added between
supply-chain and toxic-flow (toxic-flow correlates after primaries).
Verified via integration: orchestrator emits 9 WFL findings on
tests/fixtures/workflows/.
Bug fix: extractTriggers in workflow-yaml-state.mjs was collecting
sub-properties (`branches:`, `types:`) as triggers. Now tracks the
first nested indent level and ignores anything deeper.
Tests:
- 6 new cases in tests/scanners/workflow-scanner.test.mjs:
re-interp TP, no-double-count, auth-bypass TP, auth-bypass FP
(startsWith head_ref is not auth-bypass), OWASP map shape,
orchestrator import + SCANNERS array entry.
- 2 new fixtures: tp-reinterpolation.yml, auth-bypass-dependabot.yml.
- Existing 14 scanner tests + 15 state-machine tests unchanged.
Test count: 1732 -> 1738 (+6). Wave B total: +53 over baseline 1685.
Pre-compact-scan flake unchanged (passes in isolation).
Adds a scope-hopping detector to the npm install gate. When a user
installs `@<scope>/<unscoped>`, the hook now emits a MEDIUM warning
on stderr (exit 0, never blocks) if:
- `<unscoped>` matches a popular npm package (POPULAR_NPM, ~80
names from knowledge/top-packages.json), AND
- `<scope>` is not on NPM_OFFICIAL_SCOPES (built-in 22 entries) or
on policy.json `supply_chain.allowed_scopes`.
Why: an attacker publishing `@evilcorp/lodash` cannot squat the bare
`lodash` name, but they can register an unrelated scope and rely on
typo or copy-paste to trick installs. NPM_OFFICIAL_SCOPES anchors the
known-good scopes (@types, @reduxjs, @nestjs, …) so legitimate
installs stay silent.
Implementation:
- `scanners/lib/supply-chain-data.mjs`: exports POPULAR_NPM,
NPM_OFFICIAL_SCOPES, and `checkScopeHop(name, extraAllowedScopes)` —
pure function, no policy/network dependency, fully unit-testable.
- `knowledge/typosquat-allowlist.json`: mirrors NPM_OFFICIAL_SCOPES as
`npm_official_scopes`. A doc-consistency assertion ensures the two
lists never drift.
- `hooks/scripts/pre-install-supply-chain.mjs`: imports checkScopeHop,
reads `supply_chain.allowed_scopes` from policy, and pushes a
warning before existing compromised/audit checks.
Tests:
- 9 new cases in tests/hooks/pre-install-supply-chain.test.mjs:
TP @evilcorp/lodash, TP @attacker/express, allowlist @types,
allowlist @reduxjs, allowlist @modelcontextprotocol, FP unscoped
name not in top-100, bare unscoped name, policy override, defensive
non-string input, NPM_OFFICIAL_SCOPES <-> typosquat-allowlist.json
consistency.