@clear-capabilities/agentic-security-scanner 0.124.1 → 0.128.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/bin/agentic-security.js +75 -2
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +525 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/220.index.js +193 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/435.index.js +2406 -0
  9. package/dist/449.index.js +135 -0
  10. package/dist/637.index.js +1 -1
  11. package/dist/752.index.js +7 -4
  12. package/dist/801.index.js +87 -0
  13. package/dist/826.index.js +4 -1
  14. package/dist/838.index.js +1 -1
  15. package/dist/agentic-security.mjs +1 -2
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +6 -6
  18. package/src/engine.js +31 -1
  19. package/src/integrations/tickets.js +9 -3
  20. package/src/ir/CLAUDE.md +22 -17
  21. package/src/llm-validator/index.js +47 -12
  22. package/src/mcp/tools.js +108 -3
  23. package/src/posture/CLAUDE.md +10 -1
  24. package/src/posture/cache-economics.js +7 -4
  25. package/src/posture/deterministic-fix.js +65 -0
  26. package/src/posture/entrypoint-inventory.js +248 -0
  27. package/src/posture/falsification.js +121 -0
  28. package/src/posture/fix-honesty-gate.js +175 -0
  29. package/src/posture/fix-verify.js +18 -3
  30. package/src/posture/model-routing.js +126 -0
  31. package/src/posture/mttr.js +25 -0
  32. package/src/posture/provider-catalog.js +108 -0
  33. package/src/posture/root-cause-sweep.js +262 -0
  34. package/src/posture/secret-live-check.js +71 -0
  35. package/src/pr-comment.js +3 -1
  36. package/src/sast/CLAUDE.md +1 -1
  37. package/src/sast/api-authz.js +36 -0
  38. package/src/sast/file-upload.js +118 -0
  39. package/src/sast/llm-cost-advisor.js +88 -0
  40. package/src/util/untrusted.js +148 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,211 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.128.1 — patch dependency vulnerabilities (11 Dependabot alerts → 0)
4
+
5
+ Security maintenance. Cleared all 11 open Dependabot alerts by updating the two lockfiles to
6
+ patched versions (all within-major bumps, no breakage):
7
+
8
+ - **`scanner/`** — `js-yaml` 4.1 → 4.3.0 (GHSA-h67p-54hq-rp68, quadratic-complexity DoS via merge
9
+ keys). `js-yaml` is inlined into the shipped bundle, so `dist/agentic-security.mjs` was rebuilt;
10
+ full gate re-run green (`npm test` 1695/0, cve-replay 185/185).
11
+ - **`ide/vscode/`** — `undici` → 7.28.0 (incl. one high), `form-data` → 4.0.6 (high),
12
+ `markdown-it` → 14.3.0, `esbuild` → 0.28.1, `js-yaml` → 4.3.0. All transitive under
13
+ `@vscode/vsce`/`esbuild`; lockfile-only, `npm audit` now reports 0.
14
+
15
+ `npm audit` is clean (0 vulnerabilities) in both packages.
16
+
17
+ ## 0.128.0 — the agentic methodology layer + a simpler command surface
18
+
19
+ Two things landed together this release: a set of default-on **methodology annotators** that
20
+ layer agentic-hunter discipline on top of the deterministic engine, and a **consolidation** of
21
+ the command and skill surface so there's less to remember.
22
+
23
+ **Methodology layer (7 additions, all v1, all tested — see `docs/AGENTIC_METHODOLOGY_PRD.md`):**
24
+
25
+ - **Default falsification pass** (`posture/falsification.js`) — for each taint finding it tries
26
+ to *disprove* the finding by locating a context-matched control on the path, and demotes +
27
+ quarantines the ones it can block. Recall-preserving (never removes a finding, never touches
28
+ severity — like the proof gate); genuine cve-replay `pre` vulns still fire (0 false blocks,
29
+ corpus 185/185 intact). Opt out with `AGENTIC_SECURITY_NO_FALSIFICATION=1`.
30
+ - **Attack-surface completeness inventory** (`posture/entrypoint-inventory.js`) — enumerates
31
+ every entry point (HTTP / queue / cron / CLI / env / upload / webhook) with a disposition
32
+ each, on `scan.entrypointInventory`.
33
+ - **Root-cause sweep** (`posture/root-cause-sweep.js`) — from a confirmed finding, searches the
34
+ repo for sibling instances detectors missed, with a `found = candidates + mitigated`
35
+ accounting invariant, on `scan.rootCauseSweep`.
36
+ - **Meta-security hardening** (`util/untrusted.js` + `docs/AGENT_THREAT_MODEL.md`) — a tested
37
+ threat model treating attacker-authored finding text as untrusted input; escaping wired into
38
+ the PR/issue/ticket render paths.
39
+ - **Capability-based model routing** (`posture/model-routing.js`) — stamps `finding.dispatchModel`
40
+ (strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for
41
+ cost-sensitive subagent dispatch.
42
+ - **Self-improving recall harness** (`bench/realworld-recall/`) — LLM-judged (offline-degrading)
43
+ recall on real repos + a miss-analyzer that names the pipeline stage that dropped a finding
44
+ and proposes the fix. Bench-only; never in the product scan path.
45
+ - **Deterministic fix-honesty gates** (`posture/fix-honesty-gate.js`) — a residual-risk
46
+ hand-wave guard, a cited-file:line requirement for FP/safe verdicts, and FULL/MITIGATION/
47
+ WORKAROUND completeness tiers; the previously-orphaned test loop is now wired into
48
+ `apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1`.
49
+
50
+ **Simpler surface (no functionality removed — everything folds to a mode + alias):**
51
+
52
+ - Commands **12 → 10**: `/ci` folded into `/setup --ci` (+ new `/setup --predeploy`), and
53
+ `/three-agent-review` into `/triage --deep`. Old names still resolve via the
54
+ legacy-alias-redirect hook.
55
+ - Skills **11 → 7**: the four write-time guards merged into `secure-coding-guard`, and the two
56
+ explainers into `security-explain`.
57
+
58
+ **Docs:** README + ARCHITECTURE + HARNESS_COMPATIBILITY refreshed with accurate surface counts
59
+ (17 MCP tools, 10 commands, 7 skills, 5 hook events, 9 sub-agents); the SAST/SCA improvement PRD
60
+ audited and marked (16 of 25 shipped, 9 partial). Full `npm test` green (1695 tests); cve-replay
61
+ corpus 185/185, no drift.
62
+
63
+ ## 0.127.0 — cost advisor: an actual choice, not just a tip
64
+
65
+ The model-cost advisor (`hooks/model-cost-advisor.js`) has always been advisory
66
+ only — a `systemMessage` tip you read and act on yourself, by design, because a
67
+ `UserPromptSubmit` hook cannot pause for interactive input or change the model/
68
+ effort (verified against the current Claude Code hooks reference before writing
69
+ any of this, not assumed from the hook's own pre-existing comments). This
70
+ release adds a real choice on top of that hard limit, without giving up the
71
+ zero-token guarantee for anyone who doesn't opt in.
72
+
73
+ - **New opt-in `"interactive": true`** (`.agentic-security/model-optimizer.json`,
74
+ default `false`). On a qualifying prompt, the hook now additionally emits
75
+ `hookSpecificOutput.additionalContext` — billed as input tokens, unlike the
76
+ free `systemMessage` — directing Claude to call `AskUserQuestion` with three
77
+ options: keep current defaults; get the exact `/model`/`/effort` command to
78
+ run yourself (Claude cannot switch its own running model — no exception
79
+ exists); or apply the cheaper model/effort to Claude's own delegated
80
+ Task/Agent-tool sub-agent dispatches for the rest of the session — the one
81
+ axis Claude can genuinely act on directly. Every other install keeps today's
82
+ zero-token behavior unchanged; only projects that explicitly opt in pay for
83
+ the interactive path, and only on prompts that already qualify for a tip.
84
+ - The chosen sub-agent override is **sticky for the session** — persisted to
85
+ `.agentic-security/model-optimizer-state.json` (extending the existing state
86
+ file rather than adding a new one) and cleared at the next `SessionStart`,
87
+ which already does a bare overwrite of that file. A cooldown
88
+ (`interactiveCooldownTurns`, default 3) stops the directive from re-firing
89
+ its token cost if Claude doesn't act on it (e.g. a non-interactive/scripted
90
+ invocation).
91
+ - Fixed a real bug the new shape would otherwise have hit: `dispatch-user-
92
+ prompt.js`'s `mergeOutputs` previously let the legacy-alias-redirect's
93
+ `additionalContext` and the advisor's new `additionalContext` clobber each
94
+ other if both fired on the same prompt (independent triggers, so this can
95
+ genuinely co-occur) — now joined instead of last-write-wins.
96
+ - `commands/setup.md`'s `--model-optimizer` gained a matching `--interactive`
97
+ flag; `CLAUDE.md` documents the durable instruction for applying a saved
98
+ sub-agent override on future dispatches, respecting existing static
99
+ `model:` frontmatter pins (`security-triager.md`, `sca-triager.md`) and the
100
+ "task needs more capability" carve-out.
101
+
102
+ Also added `SECURITY.md` (vulnerability disclosure policy).
103
+
104
+ ## 0.126.0 — closing the find→fix loop: verified auto-fix, CWE-434/306, Fable 5 pricing
105
+
106
+ The largest remediation-side release to date. Prior to this, findings could be
107
+ *found* at scale but not *fixed* at scale — of the 677 findings on this repo's own
108
+ scan, zero could be mechanically applied. This release closes that loop end to end
109
+ while adding the detection classes and workflow polish the closed loop needed.
110
+ Full detail and rationale: `docs/FIND_AND_FIX_LOOP_PRD.md` (implemented; the file
111
+ itself is removed post-implementation per its own instructions).
112
+
113
+ **Remediation — a verified patch path for every finding, not just the ones with a
114
+ stored replacement.**
115
+ - `apply_fix` (MCP) now accepts a caller-supplied `patch` (a files map) and
116
+ **re-verifies it inline** — rescan-clean, no new ≥medium finding, lint-clean —
117
+ before writing. Previously `apply_fix` refused any finding without a stored
118
+ `fix.replacement`; now a template-only or description-only finding (the vast
119
+ majority) can be fixed, because the bytes are proven safe at write time instead
120
+ of trusted at synthesis time.
121
+ - **Deterministic zero-LLM patch synthesis** (`posture/deterministic-fix.js`) for
122
+ safe, context-independent classes — weak hash (md5/sha1→sha256), TLS
123
+ verify-off→on — materialized on demand by `synthesize_fix` and still gated by
124
+ the same inline verifier before `apply_fix` writes it.
125
+ - Regression tests are wired into the fix loop: `synthesize_fix` surfaces the
126
+ scan's existing PoC-derived `regression_test` so a fix ships with a test that
127
+ fails pre-fix and passes post-fix.
128
+ - `/fix --all` and `/find-and-fix-everything` now run independent findings in
129
+ **parallel** (serializing only same-file findings), never halt on the first
130
+ test failure, and publish the running **auto-fix acceptance rate**.
131
+ - `/fix --sca` surfaces its **upgrade break-rate** (build/test-verified upgrades
132
+ are the default; the break-rate is how often an "available" upgrade actually
133
+ wasn't safe to take).
134
+ - MTTR / SLA tracking is now live: every scan stamps `firstSeenAt`/`ageDays` and
135
+ surfaces an SLA-breach summary (`critical` 7d / `high` 30d / …).
136
+ - The Layer-3 LLM validator gained a first-class Anthropic preset
137
+ (`AGENTIC_SECURITY_LLM_PRESET=anthropic`) — previously BYO-endpoint only, so the
138
+ FP-suppression layer was reachable with just a key instead of a hand-built
139
+ endpoint.
140
+
141
+ **Detection.**
142
+ - New **CWE-434 unrestricted file-upload detector** (JS/Python) — a whole CWE
143
+ class that had zero coverage: unguarded Multer configs and writes that use the
144
+ client-supplied filename as the destination.
145
+ - New **CWE-306 missing-authentication** rule: an unauthenticated destructive
146
+ route (DELETE, or id-taking PUT/PATCH) fires only when the app authenticates
147
+ elsewhere in the codebase — so auth-detection is proven to work before the rule
148
+ trusts a "no auth found" signal, keeping it high-precision even on all-public
149
+ files.
150
+ - Corrected stale documentation in `scanner/src/ir/CLAUDE.md`: the Python CST
151
+ parser's `match`-case bodies, walrus bindings, destructuring, and comprehension
152
+ filters were already fully lowered and taint-propagating — verified end-to-end
153
+ with new flow tests, not just parser-shape tests.
154
+ - The independent-eval gate (`bench/independent-eval/`) is now **active**
155
+ (`aggregateF1`/`perFamilyRecall` floors instead of `null`) — proven to fail on a
156
+ deliberate regression and pass at the current corpus result.
157
+
158
+ **Hooks & cost.**
159
+ - Pricing tables across all five rate-table copies now include **Fable 5
160
+ ($10/$50 per MTok)** and Sonnet 5 — previously a Fable 5 session wasn't even
161
+ priced by the cache-economics reporter, and the cost advisor couldn't reason
162
+ about the flagship model at all.
163
+ - Two mechanical subagents (`security-triager`, `sca-triager`) pinned to Haiku.
164
+ - The two `UserPromptSubmit` hooks and three `PreToolUse` (Edit) hooks were each
165
+ consolidated into a single dispatcher process — halving/thirding the per-turn
166
+ node spawns. The security-critical bodyguard block is proven to survive
167
+ consolidation (a dedicated regression test asserts the exit-2 deny still
168
+ fires first and short-circuits the advisory hooks).
169
+ - The post-edit hook now offers a one-tap auto-fix inline when a fresh finding is
170
+ mechanically fixable, instead of only pointing at `/fix-all`.
171
+ - The model-cost optimizer now ships **on by default** (`mode: "advise"`) with a
172
+ predicted-vs-realized savings ledger.
173
+
174
+ **Workflow.**
175
+ - `scan --watch` wires the existing (previously unwired) watch-mode daemon:
176
+ continuous incremental re-scan on file change with a risk-delta status line.
177
+ - Opt-in, offline-degrading **live-secret validation**
178
+ (`--validate-secrets`) — labels a detected secret `live`/`dead`/`unknown` via a
179
+ read-only provider "whoami" check (GitHub, Stripe, OpenAI, SendGrid), so "this
180
+ key is LIVE" is distinguished from "you have a high-entropy string."
181
+ `--secret-history` (git-history sweep) was already wired; this release adds
182
+ the liveness half.
183
+ - Diff-scoped scans (`--pr` / `--changed-since`) now default `--incremental` on,
184
+ since a changed-file set is exactly the incremental cache's designed case.
185
+
186
+ ## 0.125.0 — multi-provider LLM cost + prompt-cache linter (Cache Economics v2, phase 1)
187
+
188
+ First slice of the Cache Economics v2 PRD (`docs/CACHE_ECONOMICS_V2_PRD.md`) — the
189
+ foundation (P1 provider detection, P2 provider catalog) + the flagship analyzer (F1
190
+ cache-hygiene + P3 per-provider model/depth recommendation). The optimizer now reaches
191
+ beyond this Claude Code session into the user's **own AI-app code**, across providers.
192
+
193
+ - **Provider catalog** (`scanner/src/posture/provider-catalog.js`): a dated, no-network
194
+ snapshot of Anthropic / OpenAI / Google Gemini / xAI — model ladder ($/1M rates), the
195
+ "depth" knob (effort / reasoning_effort / thinkingBudget), and the cache model
196
+ (explicit vs automatic vs implicit). Prices are sourced, not hardcoded-in-logic, with a
197
+ `SOURCED_AT` staleness date.
198
+ - **LLM cost/cache detector** (`scanner/src/sast/llm-cost-advisor.js`, `scanLlmCost`):
199
+ provider-aware, gated on detecting an LLM SDK (low FP), emits **advisory** findings —
200
+ (1) *prompt-cache killer*: a timestamp/UUID/random value baked into a prompt prefix
201
+ that defeats caching; (2) *over-provisioned*: a flagship model at high depth, with the
202
+ catalog's cheaper model + lower depth **in that provider's framework** as the fix
203
+ (e.g. an OpenAI app → "gpt-5.4 at reasoning_effort=low"). Severity `low`/`info` so it
204
+ never inflates security counts.
205
+
206
+ Remaining PRD phases (F2 measured cost, F3 TTL, F4 compaction, F5 pre-warm, F6 cross-
207
+ session warmth, F7 self-tuning; full P4 per-provider economics) ship next.
208
+
3
209
  ## 0.124.1 — fix: narration no longer prints a broken location
4
210
 
5
211
  Now that v0.124.0 surfaces `narration` prominently inline, a pre-existing template
@@ -113,6 +113,7 @@ Options:
113
113
  --since-baseline Only show findings NOT in the saved baseline
114
114
  --hide-proven-safe Drop findings discharged by a flow proof (provably safe)
115
115
  --secret-history Also sweep recent git history for committed secrets
116
+ --validate-secrets Label each detected secret live/dead/unknown (opt-in, network, offline-degrading)
116
117
  (removed from HEAD but recoverable from .git)
117
118
  --history-depth <n> Commits to sweep with --secret-history (default 50)
118
119
  --no-epss Skip EPSS exploit-prediction enrichment (default: enabled)
@@ -331,8 +332,40 @@ async function cmdScan(args) {
331
332
  }
332
333
  }
333
334
 
335
+ // #21 — --watch : continuous incremental re-scan on file change. Each change
336
+ // re-scans (incrementally) and writes a risk-delta to
337
+ // .agentic-security/watch-status.md that a statusline / /posture poll surfaces
338
+ // inline — so "did my edit add risk?" is answered without a manual re-scan.
339
+ // Blocks until Ctrl-C, like `jest --watch`. Opt-in, so it never affects a
340
+ // normal one-shot scan.
341
+ if (args.flags['watch']) {
342
+ process.env.AGENTIC_SECURITY_INCREMENTAL = '1';
343
+ const { watchProject, computeDelta, persistStatus, renderStatusLine } = await import('../src/posture/watch-mode.js');
344
+ process.stderr.write(`[watch] scanning ${targetAbs} on change — Ctrl-C to stop. Status → .agentic-security/watch-status.md\n`);
345
+ const seed = await runScan(targetAbs, {});
346
+ let prevFindings = seed.scan.findings || [];
347
+ await watchProject(targetAbs, async () => {
348
+ try {
349
+ const { scan } = await runScan(targetAbs, {});
350
+ const curr = scan.findings || [];
351
+ const delta = computeDelta(prevFindings, curr);
352
+ persistStatus(targetAbs, delta);
353
+ process.stderr.write('[watch] ' + renderStatusLine(delta) + '\n');
354
+ prevFindings = curr;
355
+ } catch (e) {
356
+ process.stderr.write(`[watch] rescan failed: ${e.message}\n`);
357
+ }
358
+ });
359
+ return 0; // watchProject blocks until aborted
360
+ }
361
+
334
362
  // --incremental : reuse taint summaries from prior scans for faster deep mode.
335
- if (args.flags['incremental'] || process.env.AGENTIC_SECURITY_INCREMENTAL === '1') {
363
+ // #23 — default incremental ON for diff-scoped scans (--pr / --changed-since):
364
+ // that's the cache's designed use (small changed set → its callers), so the
365
+ // PR-native path is fast by default. A full-tree scan stays non-incremental
366
+ // unless explicitly requested (blanket-default flip needs broader validation).
367
+ const _diffScoped = !!(args.flags['pr'] || args.flags['changed-since']);
368
+ if (args.flags['incremental'] || process.env.AGENTIC_SECURITY_INCREMENTAL === '1' || _diffScoped) {
336
369
  process.env.AGENTIC_SECURITY_INCREMENTAL = '1';
337
370
  }
338
371
 
@@ -566,6 +599,46 @@ async function cmdScan(args) {
566
599
  if (_isSafeStateDir(stateDir)) {
567
600
  await fsp.mkdir(stateDir, { recursive: true });
568
601
  const persistedScan = toJSON(scan, meta);
602
+ // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
603
+ // every finding carries an age, SLA breaches can be surfaced, and the fix
604
+ // loop can report time-to-clean. Best-effort; skipped under --deterministic
605
+ // so deterministic state stays byte-identical run-to-run.
606
+ if (!args.flags.deterministic) {
607
+ try {
608
+ const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
609
+ let baselineMap = new Map();
610
+ try {
611
+ const prev = JSON.parse(await fsp.readFile(path.join(stateDir, 'last-scan.json'), 'utf8'));
612
+ baselineMap = buildBaselineMap(prev);
613
+ } catch { /* first run — empty baseline, everything is firstSeen now */ }
614
+ const now = Date.now();
615
+ stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
616
+ stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
617
+ stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
618
+ // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
619
+ const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
620
+ if (!isJson) {
621
+ const sla = renderSlaSummary(persistedScan.findings || []);
622
+ if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
623
+ }
624
+ } catch { /* MTTR is best-effort — never block a scan write */ }
625
+ }
626
+ // #22 — live-secret validation (opt-in, offline-degrading). Label each
627
+ // detected secret live | dead | unknown via a read-only provider "whoami".
628
+ // "This key is LIVE and was committed N commits ago" is the P0 that matters.
629
+ if (args.flags['validate-secrets'] || process.env.AGENTIC_SECURITY_VALIDATE_SECRETS === '1') {
630
+ try {
631
+ const { checkSecretLive } = await import('../src/posture/secret-live-check.js');
632
+ let live = 0;
633
+ for (const s of (persistedScan.secrets || [])) {
634
+ const { verdict, provider } = await checkSecretLive(s);
635
+ s.liveVerdict = verdict;
636
+ if (provider) s.liveProvider = provider;
637
+ if (verdict === 'live') live++;
638
+ }
639
+ if (live > 0) process.stderr.write(`🔴 agentic-security: ${live} LIVE secret(s) validated — rotate immediately (even if already removed from HEAD).\n`);
640
+ } catch { /* best-effort, offline-degrading — never block a scan */ }
641
+ }
569
642
  const lastScanBody = JSON.stringify(persistedScan, null, 2);
570
643
  await fsp.writeFile(path.join(stateDir, 'last-scan.json'), lastScanBody);
571
644
  try {
@@ -1535,7 +1608,7 @@ description: Remediate every finding at or above a severity threshold (default:
1535
1608
  argument-hint: "[--severity critical|high|medium]"
1536
1609
  ---
1537
1610
 
1538
- Read \`.agentic-security/last-scan.json\`. For every finding at or above \`\${1:-critical}\` severity, dispatch the security-fixer subagent in sequence not in parallel, as each fix may change subsequent findings. After each batch, re-run \`/security-scan-all\` to confirm. Stop and report if any test fails.
1611
+ Read \`.agentic-security/last-scan.json\`. For every finding at or above \`\${1:-critical}\` severity, dispatch the security-fixer subagent independent findings in parallel, serializing only findings that share a file. Each fix is inline-verified by apply_fix before it lands (finding gone + no new ≥medium + lint). Do NOT halt on the first failure: record each finding's outcome and continue, then report the full list and the auto-fix acceptance rate. Re-run \`/security-scan-all\` to confirm.
1539
1612
  `,
1540
1613
  'security-report.md': `---
1541
1614
  description: Generate an HTML security report (or JSON / Markdown / SARIF).
@@ -0,0 +1,353 @@
1
+ export const id = 11;
2
+ export const ids = [11];
3
+ export const modules = {
4
+
5
+ /***/ 11:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ // ESM COMPAT FLAG
9
+ __webpack_require__.r(__webpack_exports__);
10
+
11
+ // EXPORTS
12
+ __webpack_require__.d(__webpack_exports__, {
13
+ runProjectLinter: () => (/* binding */ runProjectLinter),
14
+ verifyFix: () => (/* binding */ verifyFix),
15
+ verifyPatch: () => (/* binding */ verifyPatch)
16
+ });
17
+
18
+ // EXTERNAL MODULE: external "node:child_process"
19
+ var external_node_child_process_ = __webpack_require__(1421);
20
+ // EXTERNAL MODULE: external "node:fs"
21
+ var external_node_fs_ = __webpack_require__(3024);
22
+ // EXTERNAL MODULE: external "node:path"
23
+ var external_node_path_ = __webpack_require__(6760);
24
+ // EXTERNAL MODULE: ./src/engine.js + 524 modules
25
+ var engine = __webpack_require__(8215);
26
+ ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
27
+ // Deterministic honesty gates on fix / finding output (#7).
28
+ //
29
+ // The project's verification discipline (scanner/CLAUDE.md) exists because
30
+ // several releases shipped broken or false because work was reported as done
31
+ // without confirming the artifact changed. Two of those failure modes are
32
+ // *textual* — they live in the prose an agent emits alongside a fix — and can
33
+ // be caught deterministically, with no LLM and no network:
34
+ //
35
+ // 1. Hand-wave residual-risk prose. "The input is adequately handled",
36
+ // "future work", "tbd", "later" — vague assurances that claim safety
37
+ // without naming a concrete remaining vector. A residual you can't name
38
+ // is a residual you're guessing about; reject the guess.
39
+ //
40
+ // 2. An unbacked "this is a false positive / provably safe" verdict. Marking
41
+ // a finding safe is a coverage *reduction* — it must cite a `file:line`
42
+ // that shows why, exactly like the rules-override gate refuses to silently
43
+ // shrink coverage.
44
+ //
45
+ // Plus a conservative fix-tier classifier so a partial remediation can never be
46
+ // labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
47
+ // reject) is WORKAROUND; anything short of (sink signature changed + all callers
48
+ // routed + a discriminating test) is at most MITIGATION; only the full set with
49
+ // no partial-sanitization caveat earns FULL.
50
+ //
51
+ // Pure functions, no side effects, no throwing — safe to call from a command,
52
+ // a hook, or the MCP verify_fix path.
53
+
54
+ // Vague-assurance phrases that a real residual must never hide behind. Matched
55
+ // case-insensitively with word boundaries so "later" doesn't trip on
56
+ // "collateral" and "tbd" doesn't trip on a longer token.
57
+ const BANNED_RESIDUAL_PHRASES = Object.freeze([
58
+ 'adequately handled',
59
+ 'adequately handles',
60
+ 'properly validated',
61
+ 'properly handled',
62
+ 'handled properly',
63
+ 'handled safely',
64
+ 'future work',
65
+ 'more work needed',
66
+ 'to be done',
67
+ 'tbd',
68
+ 'later',
69
+ ]);
70
+
71
+ // A citation shaped like `file:line` — one or more non-space, non-colon chars,
72
+ // a colon, then digits. Unanchored: it need only appear somewhere in the item.
73
+ const CITATION_RE = /[^\s:]+:\d+/;
74
+
75
+ // Verdicts that assert the finding is not real and therefore demand a citation.
76
+ // Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
77
+ // FALSE_POSITIVE, false-positive, and "provably safe" all land here.
78
+ const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
79
+
80
+ function _escapeRe(s) {
81
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ }
83
+
84
+ /**
85
+ * Reject vague-assurance / hand-wave residual-risk prose.
86
+ *
87
+ * An empty or whitespace-only residual is ok — there is no residual to lie
88
+ * about. A non-empty residual is rejected when it contains any banned phrase;
89
+ * each match yields one violation naming the offending phrase.
90
+ *
91
+ * @param {string} residualText
92
+ * @returns {{ ok: boolean, violations: string[] }}
93
+ */
94
+ function checkResidualHonesty(residualText) {
95
+ const text = typeof residualText === 'string' ? residualText : '';
96
+ if (text.trim() === '') return { ok: true, violations: [] };
97
+
98
+ const violations = [];
99
+ for (const phrase of BANNED_RESIDUAL_PHRASES) {
100
+ const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
101
+ if (re.test(text)) {
102
+ violations.push(`vague-assurance phrase: "${phrase}"`);
103
+ }
104
+ }
105
+ return { ok: violations.length === 0, violations };
106
+ }
107
+
108
+ function _isCitation(item) {
109
+ if (typeof item === 'string') return CITATION_RE.test(item);
110
+ if (item && typeof item === 'object' && typeof item.location === 'string') {
111
+ return CITATION_RE.test(item.location);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function _normalizeVerdict(verdict) {
117
+ return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
118
+ }
119
+
120
+ /**
121
+ * Require a file:line citation behind a "this is not real" verdict.
122
+ *
123
+ * For a false-positive / provably-safe / safe verdict (case-insensitive; also
124
+ * accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
125
+ * citation — either a string matching /[^\s:]+:\d+/ or an object
126
+ * `{ location: "file:line" }`. Any other verdict passes unconditionally.
127
+ *
128
+ * @param {string} verdict
129
+ * @param {Array|string|object} evidence
130
+ * @returns {{ ok: boolean, violations: string[] }}
131
+ */
132
+ function requireCitedEvidence(verdict, evidence) {
133
+ if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
134
+ return { ok: true, violations: [] };
135
+ }
136
+ const items = Array.isArray(evidence)
137
+ ? evidence
138
+ : evidence == null
139
+ ? []
140
+ : [evidence];
141
+ if (items.some(_isCitation)) return { ok: true, violations: [] };
142
+ return {
143
+ ok: false,
144
+ violations: ['false-positive/safe verdict requires a file:line citation'],
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
150
+ *
151
+ * @param {object} signals
152
+ * @param {boolean} signals.sinkSignatureChanged
153
+ * @param {boolean} signals.allCallersRouted
154
+ * @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
155
+ * @param {boolean} [signals.rateLimitOnly]
156
+ * @param {boolean} [signals.docsOnly]
157
+ * @param {boolean} [signals.logOnlyNoReject]
158
+ * @param {boolean} [signals.partialSanitization]
159
+ * @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
160
+ */
161
+ function computeFixTier(signals) {
162
+ const s = signals && typeof signals === 'object' ? signals : {};
163
+ if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
164
+ const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
165
+ if (s.partialSanitization || !complete) return 'MITIGATION';
166
+ return 'FULL';
167
+ }
168
+
169
+ /**
170
+ * Compose the three gates for a single fix's output.
171
+ *
172
+ * ok = residual-honesty ok AND evidence-citation ok, further constrained by the
173
+ * tier/residual consistency invariant:
174
+ * - a FULL tier must NOT carry a residual (a full fix has nothing left);
175
+ * - a non-FULL tier MUST document a residual (say what's still open).
176
+ *
177
+ * @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
178
+ * @returns {{ ok: boolean, tier: string, violations: string[] }}
179
+ */
180
+ function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
181
+ const tier = computeFixTier(signals);
182
+ const residualCheck = checkResidualHonesty(residual);
183
+ const evidenceCheck = requireCitedEvidence(verdict, evidence);
184
+
185
+ const violations = [...residualCheck.violations, ...evidenceCheck.violations];
186
+ let ok = residualCheck.ok && evidenceCheck.ok;
187
+
188
+ const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
189
+ if (tier === 'FULL' && !residualEmpty) {
190
+ violations.push('FULL tier cannot carry a residual');
191
+ ok = false;
192
+ }
193
+ if (tier !== 'FULL' && residualEmpty) {
194
+ violations.push('non-FULL tier must document a residual');
195
+ ok = false;
196
+ }
197
+
198
+ return { ok, tier, violations };
199
+ }
200
+
201
+ const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
202
+
203
+ ;// CONCATENATED MODULE: ./src/posture/fix-verify.js
204
+ // Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
205
+ //
206
+ // Given a candidate patch (the new file content + the finding stableId being
207
+ // fixed), verify it:
208
+ //
209
+ // 1. The original finding's stableId no longer fires on the patched file.
210
+ // 2. No new findings at severity ≥ medium were introduced by the patch.
211
+ // 3. The project's existing linter (when present) passes on the patched file.
212
+ //
213
+ // If any of those fail, the caller is expected to NOT apply the patch and
214
+ // instead surface a "fix plan" — a numbered list of steps the engineer can
215
+ // follow — rather than dump a broken patch on the user.
216
+
217
+
218
+
219
+
220
+
221
+
222
+
223
+ const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
224
+
225
+ // Run a focused re-scan over just the patched file(s) using the in-memory
226
+ // engine. No filesystem write needed — we hand the new content in via the
227
+ // fileContents map.
228
+ async function verifyPatch({
229
+ scanRoot,
230
+ originalFindingStableId,
231
+ files, // { [relPath]: newContent }
232
+ depFileContents = {},
233
+ } = {}) {
234
+ if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
235
+ const fileContents = { ...files };
236
+ let scan;
237
+ try {
238
+ scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot }, () => {});
239
+ } catch (e) {
240
+ return { ok: false, reason: 'rescan-failed', error: e.message };
241
+ }
242
+ const findings = (scan && scan.findings) || [];
243
+ const stillHasOriginal = !!originalFindingStableId &&
244
+ findings.some(f => f.stableId === originalFindingStableId);
245
+ if (stillHasOriginal) {
246
+ return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
247
+ }
248
+ const introducedHighOrAbove = findings.filter(f =>
249
+ (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
250
+ // Don't count findings on lines outside the patched files — but our
251
+ // fileContents map IS the patched files, so every finding is in-scope.
252
+ return {
253
+ ok: introducedHighOrAbove.length === 0,
254
+ reason: introducedHighOrAbove.length === 0 ? 'verified' : 'introduced-new-findings',
255
+ introduced: introducedHighOrAbove.map(f => ({
256
+ vuln: f.vuln, file: f.file, line: f.line, severity: f.severity,
257
+ stableId: f.stableId,
258
+ })),
259
+ };
260
+ }
261
+
262
+ // Detect which linter the project uses and run it on the patched files.
263
+ // Returns { ok, runner, output } or { ok: true, runner: 'none' } when no
264
+ // linter is configured (silent pass).
265
+ function runProjectLinter(scanRoot, filePaths) {
266
+ if (!scanRoot || !Array.isArray(filePaths) || filePaths.length === 0) {
267
+ return { ok: true, runner: 'none' };
268
+ }
269
+ const has = (p) => { try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, p)); } catch { return false; } };
270
+ // Pick the linter by config file present in the repo root.
271
+ const jsFiles = filePaths.filter(f => /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(f));
272
+ const pyFiles = filePaths.filter(f => /\.py$/i.test(f));
273
+ const goFiles = filePaths.filter(f => /\.go$/i.test(f));
274
+ const javaFiles = filePaths.filter(f => /\.java$/i.test(f));
275
+
276
+ if (jsFiles.length && (has('.eslintrc') || has('.eslintrc.json') || has('.eslintrc.js') || has('eslint.config.js') || has('eslint.config.mjs'))) {
277
+ return runLinter(scanRoot, 'eslint', ['--no-error-on-unmatched-pattern', ...jsFiles]);
278
+ }
279
+ if (pyFiles.length && (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml'))) {
280
+ return runLinter(scanRoot, 'ruff', ['check', ...pyFiles]);
281
+ }
282
+ if (pyFiles.length && has('.flake8')) {
283
+ return runLinter(scanRoot, 'flake8', pyFiles);
284
+ }
285
+ if (goFiles.length && (has('.golangci.yml') || has('.golangci.yaml'))) {
286
+ return runLinter(scanRoot, 'golangci-lint', ['run', ...goFiles]);
287
+ }
288
+ if (javaFiles.length && has('checkstyle.xml')) {
289
+ return runLinter(scanRoot, 'checkstyle', ['-c', 'checkstyle.xml', ...javaFiles]);
290
+ }
291
+ return { ok: true, runner: 'none' };
292
+ }
293
+
294
+ function runLinter(cwd, cmd, args) {
295
+ let r;
296
+ try {
297
+ r = (0,external_node_child_process_.spawnSync)(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000 });
298
+ } catch (e) {
299
+ return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing', error: e.message };
300
+ }
301
+ if (r.error && r.error.code === 'ENOENT') {
302
+ return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing' };
303
+ }
304
+ if (r.status === null) {
305
+ return { ok: false, runner: cmd, reason: 'timed-out', output: (r.stderr || r.stdout || '').slice(-2000) };
306
+ }
307
+ return {
308
+ ok: r.status === 0,
309
+ runner: cmd,
310
+ exitCode: r.status,
311
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
312
+ };
313
+ }
314
+
315
+ // Top-level verify: re-scan + lint. Returns the combined verdict + a
316
+ // human-readable summary string suitable for surfacing to the user.
317
+ // Addition #7 — deterministic honesty gates on fix output. When the caller
318
+ // supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
319
+ // security-fixer agent's residual-risk text + completeness signals — the fix's
320
+ // claims are checked mechanically (no hand-wave residual prose, a cited
321
+ // file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
322
+ // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
323
+ // (the deterministic MCP write path, which has no claims to check) the honesty
324
+ // gate is skipped and behavior is unchanged.
325
+ async function verifyFix({
326
+ scanRoot,
327
+ originalFindingStableId,
328
+ files,
329
+ depFileContents,
330
+ fixMeta,
331
+ } = {}) {
332
+ const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
333
+ const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
334
+ let honesty = null;
335
+ if (fixMeta && typeof fixMeta === 'object') {
336
+ try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
337
+ }
338
+ const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
339
+ const summary = [
340
+ `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
341
+ `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
342
+ : lint.skipped ? `${lint.runner} not installed`
343
+ : lint.ok ? `${lint.runner} PASS`
344
+ : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
345
+ honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
346
+ ].filter(Boolean).join('\n');
347
+ return { ok, rescan, lint, honesty, summary };
348
+ }
349
+
350
+
351
+ /***/ })
352
+
353
+ };