diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index aeab751..b8ae11f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "llm-security", "description": "Security scanning, auditing, and threat modeling for Claude Code projects. Detects secrets, validates MCP servers, assesses security posture, and generates threat models aligned with OWASP LLM Top 10.", - "version": "7.8.1" + "version": "7.8.2" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b866834..ea770a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,73 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [7.8.2] - 2026-07-18 + +Security patch. Fixes five defects from the v7.8.1 completion review, four of +which caused a scanner or hook to fail silently — reporting success while the +check it was named for did not run. No feature changes. 1901 tests, 0 fail. + +### Fixed + +- **HIGH — bare root/home targets bypassed the rm block** + (`hooks/scripts/pre-bash-destructive.mjs`). The BLOCK rule's target + alternation ended in a shared `\b`, and a word boundary cannot hold after `/` + or `~` at end-of-command. `rm -rf /`, `rm -rf ~`, `rm -rf /*`, `rm -fr /` and + the sudo-prefixed forms all fell through to WARN (exit 0) — advisory only, + command executed. Only targets starting with a word character (`/etc`, + `/usr`) were ever blocked. `\b` now applies to the `$HOME` alternative alone, + where it is meaningful. + +- **HIGH — entropy suppression keyed off the absolute path** + (`scanners/entropy-scanner.mjs`). The test/fixture suppression rule matched + `/(test|spec|fixture|mock|__test__|__spec__)/i` against the **absolute** + path, so any directory name above the scan root silenced every entropy + finding in the entire target while still reporting status `ok`. The rule now + keys off the path relative to the scan root. + +- **HIGH — one unparseable JetBrains plugin crashed the whole ide-scan** + (`scanners/ide-extension-scanner.mjs`). The two manifest parsers disagree on + how they signal failure: `parseVSCodeExtension` returns bare `null`, + `parseIntelliJPlugin` returns a truthy `{ manifest: null, warnings }`. Only + the bare-null form was guarded, so every JetBrains failure path dereferenced + `manifest.hasSignature`; the TypeError escaped through `mapConcurrent`'s + unguarded `Promise.all` and aborted the scan of every other installed + extension. Guard widened, plus per-extension fault isolation at the call + site. + +- **HIGH — obfuscated injections were reported but not stripped** + (`scanners/content-extractor.mjs`). `stripInjection` scanned both the raw and + the decoded text but removed matches only via a literal replace against the + raw text. For a decoded-only match, `match[0]` is the decoded string, which + by construction does not occur in the raw text — so the replace was a silent + no-op and the encoded payload reached the LLM agent verbatim via + `sanitized_content`, alongside a finding announcing it. Every obfuscation the + normalizer exists to defeat was affected. Decoded-only matches are now + removed by redacting the source line; multi-line payloads that cannot be + attributed are flagged `unstripped: true` rather than left silently. + +- **Out-of-range numeric character reference emptied a plugin.xml field** + (`scanners/lib/ide-extension-parser.mjs`). `decodeEntities` guarded + `parseInt` with `Number.isFinite`, which bounds nothing, so any code point + above `0x10FFFF` made `String.fromCodePoint` raise `RangeError`. The + no-throw contract held (the per-field `safe()` wrapper catches it), but the + affected field was discarded and replaced with `''`. Undecodable references + are now left literal. Filed as HIGH; it is lower — such a document is not + well-formed XML, so the plugin would not load in IntelliJ either. + +### Changed + +- `stripInjection` (content-extractor) and `scanOneExtension` (ide-extension- + scanner) are exported for testing; `content-extractor.mjs` runs `main()` + behind the standard `isMain` guard so importing it no longer executes the + CLI. The remote-scan injection boundary had no direct test coverage before + this release. + +### Removed + +- `tests/hooks/probe-rm.mjs` — a self-labelled temporary debug probe with no + assertions, pointing at a hardcoded path into the installed marketplace copy. + ## [7.8.1] - 2026-07-18 Security patch. Fixes a CRITICAL command-injection defect in the auto-cleaner diff --git a/CLAUDE.md b/CLAUDE.md index e72640f..b38ee6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,10 @@ -# LLM Security Plugin (v7.8.1) +# LLM Security Plugin (v7.8.2) Security scanning, auditing, and threat modeling for Claude Code projects. 5 frameworks: OWASP LLM Top 10, Agentic AI Top 10 (ASI), Skills Top 10 (AST), MCP Top 10, AI Agent Traps (DeepMind). 1820+ unit, integration, and end-to-end tests (`tests/e2e/` covers the multi-hook attack chain, multi-session state simulation, and the full scan-orchestrator pipeline); mutation-testing coverage not published. -Release notes for v7.0.0 → v7.8.1: see `docs/version-history.md` — read on demand. +Release notes for v7.0.0 → v7.8.2: see `docs/version-history.md` — read on demand. + +**v7.8.2 highlights** — Security patch, no feature changes. Five defects from the v7.8.1 completion review, four sharing one failure mode: **the check reported success without running**. (1) `hooks/scripts/pre-bash-destructive.mjs` did not block `rm -rf /` or `rm -rf ~` — the target alternation `(?:\/|~|\$HOME)\b` ended in a word boundary that cannot hold after `/` or `~` at end-of-command, so the bare forms the rule is named for fell through to WARN (exit 0, command executed) while `/etc` and `$HOME` blocked normally, making the rule look functional from either end. (2) `scanners/entropy-scanner.mjs` matched its test/fixture suppression against the **absolute** path, so any ancestor directory named `test`/`spec`/`fixture`/`mock` silenced every entropy finding in the target while still returning status `ok`; it now keys off the relative path. (3) `scanners/ide-extension-scanner.mjs` guarded only `parseVSCodeExtension`'s bare-`null` failure signal, not `parseIntelliJPlugin`'s truthy `{ manifest: null, warnings }`, so any malformed JetBrains plugin dereferenced `manifest.hasSignature`; the TypeError escaped `mapConcurrent`'s unguarded `Promise.all` and aborted the scan of every other installed extension. Guard widened + per-extension fault isolation. (4) `scanners/content-extractor.mjs` — the remote-scan injection boundary — detected obfuscated injections but did not strip them: a decoded-only `match[0]` never occurs in the raw text, so the literal replace was a silent no-op and the payload reached the agent verbatim via `sanitized_content` alongside a finding announcing it. Removal is now line-level; unattributable multi-line payloads carry `unstripped: true`. This boundary had no direct test coverage before v7.8.2. (5) `scanners/lib/ide-extension-parser.mjs` emptied any plugin.xml field holding a character reference above `0x10FFFF` (`Number.isFinite` bounds nothing) — filed as HIGH, actually lower, since such a document is not well-formed XML. **v7.8.1 highlights** — Security patch, no feature changes. Fixes a CRITICAL command injection in `scanners/auto-cleaner.mjs`: `validateContent()` syntax-checked candidate `.mjs`/`.js`/`.cjs` content via ``execSync(`node --check "${tmpPath}"`)``, where `tmpPath` derives from the **untrusted scanned-repo filename**. The v7.8.0 F-2 guard checks path containment but neither strips nor quotes shell metacharacters, so a file named ``x";;".mjs`` closes the interpolated quote and injects a command; since `/security clean` runs live by default, scanning a hostile repository sufficed for arbitrary local command execution (live-PoC verified). Both subprocess sites — the syntax check and the CLI's inline scan-orchestrator fallback — now use `spawnSync` with an argv array, so no shell parses a path. Defense-in-depth: `applyFixes()` refuses findings whose `file` carries shell/control metacharacters, surfaced as `skipped`. `validateContent` is now exported so regression tests can drive the sink directly — the guard would otherwise mask a re-introduced shell. diff --git a/README.md b/README.md index d6d61e6..661863d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ *AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* -![Version](https://img.shields.io/badge/version-7.8.1-blue) +![Version](https://img.shields.io/badge/version-7.8.2-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Commands](https://img.shields.io/badge/commands-20-orange) ![Agents](https://img.shields.io/badge/agents-6-orange) @@ -628,6 +628,7 @@ demonstrations — each with `README.md`, fixture, run script, and | Version | Date | Highlights | |---------|------|------------| +| **7.8.2** | 2026-07-18 | **Silent-failure fixes from the completion review (HIGH).** Five defects, four sharing one failure mode: the check reported success without running. `hooks/scripts/pre-bash-destructive.mjs` did not block `rm -rf /` or `rm -rf ~` — the target alternation ended in a `\b` that cannot hold after a non-word character, so the bare forms the rule is named for fell through to WARN (exit 0, command executed) while `/etc` and `$HOME` blocked normally. `scanners/entropy-scanner.mjs` matched its test/fixture suppression against the **absolute** path, so any ancestor directory named `test`/`spec`/`fixture`/`mock` silenced every finding in the target and still returned status `ok`. `scanners/ide-extension-scanner.mjs` guarded only `parseVSCodeExtension`'s bare-`null` failure signal, not `parseIntelliJPlugin`'s truthy `{ manifest: null }`, so any malformed JetBrains plugin threw a TypeError that escaped `mapConcurrent`'s unguarded `Promise.all` and aborted the scan of every other extension. `scanners/content-extractor.mjs` detected obfuscated injections but did not remove them: a decoded-only `match[0]` never occurs in the raw text, so the literal replace was a no-op and the payload reached the LLM agent verbatim through `sanitized_content` — removal is now line-level, with unattributable multi-line payloads flagged `unstripped`. `scanners/lib/ide-extension-parser.mjs` emptied any plugin.xml field containing a character reference above `0x10FFFF`. No feature changes. 1901 tests, 0 fail. | | **7.8.1** | 2026-07-18 | **Auto-cleaner command-injection fix (CRITICAL).** `scanners/auto-cleaner.mjs` syntax-checked candidate `.mjs`/`.js`/`.cjs` content via ``execSync(`node --check "${tmpPath}"`)``, where `tmpPath` derives from the untrusted scanned-repo **filename**. The v7.8.0 F-2 guard checks path containment but does not strip or quote shell metacharacters, so a file named ``x";;".mjs`` closes the interpolated quote and injects a command — and `/security clean` runs live by default, making a hostile repository sufficient for arbitrary local command execution. Reproduced with a live PoC before the fix. Both subprocess sites (syntax check + the CLI scan-orchestrator fallback) now use `spawnSync` with an argv array, so no shell parses a path. Defense-in-depth: `applyFixes()` refuses findings whose `file` carries shell/control metacharacters, reported as `skipped`. Regression coverage is split across both layers so the guard cannot mask a re-introduced shell in the sink. No feature changes. 1865 tests, 0 fail. | | **7.8.0** | 2026-06-20 | **TRG/SIG/AST deep-scan scanners.** Three new deterministic deep-scan scanners targeting the skills/agents attack surface, each with its own finding prefix, OWASP/AST mapping, policy block, and graceful-skip behaviour. **TRG** (`scanners/trigger-scanner.mjs`) inspects command/agent/skill `name` + `description` frontmatter for activation-surface abuse: `TRG-shadow` (name collides with a built-in and intercepts it), `TRG-baiting` (maximally-activating phrases that bait indiscriminate invocation), `TRG-broad` (generic name + universal-applicability claim); descriptions pass the decode pipeline first so obfuscated baiting still trips (LLM06/AST04). **SIG** (`scanners/signature-scanner.mjs`) is a pure-Node known-malware *identity* engine (webshells, reverse shells, cryptominers, hacktools) that tests each signature against both raw bytes and the decode pipeline (base64/hex/url/entity/unicode, homoglyph fold, rot13), so obfuscated known-malware a byte-matcher misses is still caught; rules ship in `knowledge/signatures.json` (LLM03/LLM02). **AST** (`scanners/ast-taint-scanner.mjs`) shells out to a PARSE-ONLY `python3` helper (`scanners/lib/py-ast-taint.py`) for variable-level, scope-aware Python taint analysis — higher recall than the ~70% regex `taint-tracer.mjs` — and falls back to the regex tracer when `python3` is unavailable, so the scan never hard-fails; the helper only `ast.parse`s the target and never executes it (LLM01/LLM02/AST02). Built behind a security-fix gate (F-1/F-2/F-3 shell-injection + path-traversal fixes landed first). 1863 tests, 0 fail. | | **7.7.2** | 2026-05-19 | **Language consistency pass.** Norwegian had crept into surface text across v7.5-v7.7. Per the `~/.claude/CLAUDE.md` convention (English for code and documentation, Norwegian for dialog only), this release translates: the HTML Report-step appended by all 18 skill commands, the canonical CLI renderer `scripts/lib/report-renderers.mjs` (display strings + JS comments), the playground UI strings, the `skill-scanner-agent` and `mcp-scanner-agent` system prompts, the Recent versions table and playground architecture prose in this README, the v7.7.x highlights in `CLAUDE.md`, and the llm-security entries in the marketplace root `README.md` + `CLAUDE.md`, plus six table cells in `docs/scanner-reference.md`. Demo-state fixture content for the `dft-komplett-demo` project (intentional Norwegian persona) and regex alternations that match Norwegian-language report markdown (`/^high\|^høy/`, `/resolution\|løsning/`) were preserved. No scanner, hook, or behavior changes — purely surface text. | diff --git a/docs/version-history.md b/docs/version-history.md index 01f0b00..8a6ada8 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -2,6 +2,73 @@ Per-release notes for v7.0.0 onward. Imported from `CLAUDE.md` via `@docs/version-history.md`. +## v7.8.2 — Silent-failure fixes from the completion review (HIGH) + +Security patch covering five defects found in the v7.8.1 completion review. No +feature changes; full suite green at 1901/0 (1865 + 36 new regression tests). + +The common thread in four of the five is **silent failure**: the scanner or +hook reported success while the check it is named for did not run. That is the +worst failure mode for a security tool, because a clean report is +indistinguishable from a clean target. + +- **`hooks/scripts/pre-bash-destructive.mjs`** — the rule named "Filesystem + root destruction (rm -rf /)" did not block `rm -rf /`. Its target + alternation `(?:\/|~|\$HOME)\b` ended in a word-boundary assertion, which + cannot hold after `/` or `~` at end-of-command. Measured: `rm -rf /`, + `rm -rf ~`, `rm -rf /*`, `rm -fr /` and `sudo rm -rf /` all fell through to + WARN — advisory only, exit 0, command executed. `rm -rf $HOME` and + `rm -rf /usr` were blocked, which is why the gap survived: the rule looked + functional from either end. The `\b` now sits on the `$HOME` alternative + alone, so `$HOMEDIR` is still not swallowed. The prior test file had encoded + the defect as expected behaviour, with a NOTE attributing it to merged `-rf` + flags — a misdiagnosis, since `rm -rf /etc` blocks fine with merged flags. + +- **`scanners/entropy-scanner.mjs`** — the "test fixtures intentionally contain + example secrets" suppression matched against the **absolute** path. Any + ancestor directory name containing `test`, `spec`, `fixture` or `mock` + therefore suppressed every entropy finding in the whole target: a repository + cloned into a CI workspace, or checked out beneath a folder named `testing`, + reported zero secrets with status `ok`. The rule now keys off the path + relative to the scan root, which was already computed and passed alongside + it. Genuine fixture suppression (a `*.test.mjs` file, a relative `tests/` + directory) is unchanged. + +- **`scanners/ide-extension-scanner.mjs`** — `parseVSCodeExtension` signals + failure as bare `null`; `parseIntelliJPlugin` signals it as a **truthy** + `{ manifest: null, warnings }`. Only the former was guarded, so all five + JetBrains failure paths (no `lib/`, `lib/` not a directory, `lib/` + unreadable, no jars, no extractable jar) reached `manifest.hasSignature` and + threw. `mapConcurrent` awaited without a per-item catch, so `Promise.all` + rejected and the entire ide-scan aborted — one malformed plugin directory + took down the scan of every other installed extension, including, in an + audit context, a plugin that is malformed precisely because it is hostile. + +- **`scanners/content-extractor.mjs`** — this is the remote-scan indirection + layer: agents are supposed to see a sanitized evidence package, never raw + hostile content. `stripInjection` scanned the raw text *and* its decoded + form, but removed matches with `sanitized.replace(match[0], …)` against the + raw text only. A decoded-only `match[0]` does not occur in the raw text by + construction, so the replace silently did nothing: the payload was handed to + the agent verbatim through `sanitized_content` while the report announced a + critical injection. Removal now redacts the offending source line, since + decoding is not length-preserving and decoded offsets cannot be mapped back. + A payload split across several lines still cannot be attributed; those + findings carry `unstripped: true` instead of failing quietly. Whole-file + redaction was rejected — `normalizeForScan` base64-decodes any long blob, so + a benign asset could blank a file's entire evidence. + +- **`scanners/lib/ide-extension-parser.mjs`** — `decodeEntities` guarded + `parseInt` with `Number.isFinite`, which bounds nothing, so a character + reference above `0x10FFFF` raised `RangeError` in `String.fromCodePoint`. + Contrary to how this was filed, the documented no-throw contract held: the + per-field `safe()` wrapper catches it. The real effect was quieter — the + field was replaced with `''`, so a `` carrying one such reference + parsed as an empty name and name-based checks (JetBrains typosquat + detection) ran against nothing. Severity is below HIGH: a document with a + code point above `0x10FFFF` is not well-formed XML, so IntelliJ would reject + the plugin as well. Undecodable references are now left literal. + ## v7.8.1 — Auto-cleaner command-injection fix (CRITICAL) Security patch for a CRITICAL defect introduced with the v7.8.0 auto-cleaner diff --git a/package.json b/package.json index f371aa0..a040270 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "llm-security", - "version": "7.8.1", + "version": "7.8.2", "description": "Security scanning, auditing, and threat modeling for Claude Code projects", "type": "module", "bin": { diff --git a/tests/scanners/ide-extension-null-manifest.test.mjs b/tests/scanners/ide-extension-null-manifest.test.mjs index 74906eb..854e983 100644 --- a/tests/scanners/ide-extension-null-manifest.test.mjs +++ b/tests/scanners/ide-extension-null-manifest.test.mjs @@ -23,7 +23,10 @@ after(async () => { }); async function makePluginRoot(name) { - const root = await mkdtemp(join(tmpdir(), 'llmsec-jb-plugin-')); + // Prefix must NOT start with `llmsec-jb-`: jetbrains-parser.test.mjs asserts + // that no `llmsec-jb-*` directory survives anywhere in tmpdir, and that + // assertion is global, so a shared prefix collides depending on test order. + const root = await mkdtemp(join(tmpdir(), 'llmsec-nullmanifest-')); created.push(root); const dir = join(root, name); await mkdir(dir, { recursive: true });