@clear-capabilities/agentic-security-scanner 0.124.0 → 0.127.0

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.
@@ -1 +1 @@
1
- a89f9898e9c186c99eef3e08ab7a5bf886af031eb068aff011ff617d3a12a08e agentic-security.mjs
1
+ 424585bd11f792465c48ad4fcf7bcb61be3b3351b80f3fdb500eb7f89cb6755c agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.124.0",
3
+ "version": "0.127.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -60,13 +60,13 @@
60
60
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs",
61
61
  "test": "npm run test:smoke && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
62
62
  "test:smoke": "node --test test/smoke.test.js",
63
- "test:sast": "node --test test/llm.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
64
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js",
65
- "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js",
63
+ "test:sast": "node --test test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
64
+ "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js",
65
+ "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js",
66
66
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
67
67
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
68
68
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js",
69
- "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js && node ../scripts/lint-command-descriptions.mjs",
69
+ "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js && node ../scripts/lint-command-descriptions.mjs",
70
70
  "test:eval": "node --test test/independent-eval.test.js",
71
71
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
72
72
  "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
package/src/engine.js CHANGED
@@ -11,6 +11,7 @@ import { createRequire } from 'node:module';
11
11
  const _require = createRequire(import.meta.url);
12
12
  import { scanLLM } from './sast/llm.js';
13
13
  import { scanLLMOwasp } from './sast/llm-owasp.js';
14
+ import { scanLlmCost } from './sast/llm-cost-advisor.js';
14
15
  import { scanBusinessLogic } from './sast/logic.js';
15
16
  import { scanPipeline } from './sast/pipeline.js';
16
17
  import { scanMCP } from './sast/mcp-audit.js';
@@ -52,6 +53,7 @@ import { deadBranchRanges as _deadBranchRanges, isLineInDeadRange as _isLineInDe
52
53
  import { scanJavaDeserialization } from './sast/java-deserialization.js';
53
54
  import { scanJwtExp } from './sast/jwt-exp.js';
54
55
  import { scanZipSlip } from './sast/zip-slip.js';
56
+ import { scanFileUpload } from './sast/file-upload.js';
55
57
  import { scanHostHeader } from './sast/host-header.js';
56
58
  import { scanPythonSinks } from './sast/python-sinks.js';
57
59
  import { scanCSharp } from './sast/csharp.js';
@@ -7396,6 +7398,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7396
7398
  const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
7397
7399
  aF.push(...scanLLM(p,c));
7398
7400
  aF.push(...scanLLMOwasp(p,c));
7401
+ aF.push(...scanLlmCost(p,c));
7399
7402
  aLogic.push(...scanBusinessLogic(p,c));
7400
7403
  aF.push(...scanPipeline(p,c));
7401
7404
  aF.push(...scanContainer(p,c));
@@ -7429,6 +7432,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7429
7432
  aF.push(...scanJavaDeserialization(p,c));
7430
7433
  aF.push(...scanJwtExp(p,c));
7431
7434
  aF.push(...scanZipSlip(p,c));
7435
+ aF.push(...scanFileUpload(p,c));
7432
7436
  aF.push(...scanHostHeader(p,c));
7433
7437
  aF.push(...scanPythonSinks(p,c));
7434
7438
  aF.push(...scanCSharp(p,c));
package/src/ir/CLAUDE.md CHANGED
@@ -76,23 +76,28 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
76
76
  Each of these is a real failure mode; the regex fallback keeps the scan
77
77
  producing findings instead of returning empty.
78
78
 
79
- ### What CST still doesn't model
80
-
81
- The helper builds a CFG only for shapes the dataflow engine actually
82
- consumes. The following still emit `kind: 'noop'` nodes (future work):
83
-
84
- - `match` case bodies (the function is captured; the per-case taint flow
85
- isn't lowered)
86
- - destructuring assignment (`a, b = req.body`) only single-target
87
- assignments get a precise `target` field
88
- - comprehension generators (`for x in iter`) the iter expression is
89
- visible via the elt, but the generator's own `if` filters aren't yet
90
- modeled
91
- - walrus `:=` at expression position the RHS flows forward, but the
92
- named binding isn't tracked as a separate variable
93
-
94
- These are deliberate to keep the CFG bounded; the regex parser's behavior
95
- was strictly worse (it dropped the entire function).
79
+ ### What CST models (and the one remaining limit)
80
+
81
+ The helper now lowers and the dataflow engine propagates taint through — all
82
+ of the constructs this section once listed as unmodeled. Verified end-to-end in
83
+ `test/parser-py-cst.test.js` (`#16` flow tests):
84
+
85
+ - `match` case bodies — each `case` arm lowers to an `if` (the pattern) plus its
86
+ body block; a capture pattern (`case Foo(x)`) emits an assign for the binding.
87
+ Taint flows source → through a case body → sink.
88
+ - walrus `:=` the named binding is tracked as its own assign, both at statement
89
+ position and inside `if`/`while` tests (`_emit_walrus_assigns`).
90
+ - comprehension generators — the loop-var assign from the iter AND the
91
+ generator's own `if` filters are emitted (`for x in iter if cond`).
92
+ - destructuring assignment (`a, b = expr`) — one assign per target, sourced from
93
+ the element (`member[]`) of the RHS.
94
+
95
+ **Remaining limit (a deep-engine collection-element trait, NOT a dropped CFG
96
+ node):** taint carried through the *element* of a destructured tuple or a
97
+ comprehension result — `a, b = src1, src2; sink(a)` or `xs = [src…]; sink(xs[0])`
98
+ — does not always reach a finding. That's the collection-element-taint limitation
99
+ tracked in `../dataflow/CLAUDE.md`; the CFG nodes are present, the element-level
100
+ propagation is the open item.
96
101
 
97
102
  ## IR shape contract
98
103
 
@@ -92,10 +92,45 @@ Reply now with the JSON object on the last line of your response. Nothing else a
92
92
  `;
93
93
 
94
94
  function endpointConfig() {
95
+ // Explicit BYO endpoint always wins (unchanged behaviour).
95
96
  const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
96
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
97
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown';
98
- return endpoint ? { endpoint, apiKey, model } : null;
97
+ if (endpoint) {
98
+ return { endpoint, apiKey: process.env.AGENTIC_SECURITY_LLM_API_KEY, model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown', preset: null };
99
+ }
100
+ // #18 — first-class Anthropic preset. Opt-in via AGENTIC_SECURITY_LLM_PRESET=anthropic
101
+ // + a key (AGENTIC_SECURITY_LLM_API_KEY or ANTHROPIC_API_KEY): makes the FP-suppression
102
+ // validator reachable with just a key — no BYO endpoint URL or request-shape wrangling.
103
+ // Offline-degrading: no key → null (validator no-ops; no runtime cloud call by default).
104
+ if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'anthropic') {
105
+ const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.ANTHROPIC_API_KEY;
106
+ if (!apiKey) return null;
107
+ return {
108
+ endpoint: 'https://api.anthropic.com/v1/messages',
109
+ apiKey,
110
+ model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
111
+ preset: 'anthropic',
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // Shape the request for the target: the Anthropic Messages API needs an
118
+ // x-api-key header (added by the caller), an anthropic-version header, and a
119
+ // {model, max_tokens, messages:[…]} body with the reply in content[].text. The
120
+ // generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
121
+ function buildRequest(model, prompt, preset) {
122
+ if (preset === 'anthropic') {
123
+ return {
124
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
125
+ body: { model, max_tokens: 512, messages: [{ role: 'user', content: prompt }] },
126
+ extractText: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
127
+ };
128
+ }
129
+ return {
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: { prompt, model },
132
+ extractText: (j) => (j && (j.response || j.text || j.content || j.output || j.choices?.[0]?.message?.content || j.message?.content)) || '',
133
+ };
99
134
  }
100
135
 
101
136
  function ensureCacheDir(scanRoot) {
@@ -179,17 +214,17 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
179
214
  .replace('{{context}}', sterileContext || '(no surrounding code available)');
180
215
  }
181
216
 
182
- async function callEndpoint(endpoint, apiKey, model, prompt) {
183
- const headers = { 'Content-Type': 'application/json' };
184
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
185
- const body = { prompt, model };
217
+ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
218
+ const { headers, body, extractText } = buildRequest(model, prompt, preset);
219
+ if (apiKey) {
220
+ if (preset === 'anthropic') headers['x-api-key'] = apiKey;
221
+ else headers['Authorization'] = `Bearer ${apiKey}`;
222
+ }
186
223
  try {
187
224
  const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
188
225
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
189
226
  const j = await r.json().catch(() => null);
190
- const text = (j && (j.response || j.text || j.content || j.output ||
191
- j.choices?.[0]?.message?.content || j.message?.content)) || '';
192
- return { ok: true, text: String(text) };
227
+ return { ok: true, text: String(extractText(j) || '') };
193
228
  } catch (e) {
194
229
  return { ok: false, error: e.message };
195
230
  }
@@ -329,7 +364,7 @@ export async function validateOne(finding, fileContents, scanRoot) {
329
364
  const challenge = crypto.randomBytes(8).toString('hex');
330
365
  const nonce = crypto.randomBytes(8).toString('hex');
331
366
  const prompt = renderPrompt(finding, fileContents, challenge, nonce);
332
- const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt);
367
+ const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
333
368
  if (!resp.ok) {
334
369
  finding.validator_verdict = 'unvalidated';
335
370
  finding.unvalidated = true;
@@ -436,4 +471,4 @@ export function applyValidatorVerdicts(findings) {
436
471
  return { kept, dropped };
437
472
  }
438
473
 
439
- export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey };
474
+ export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest };
package/src/mcp/tools.js CHANGED
@@ -17,6 +17,7 @@ import * as fsp from 'node:fs/promises';
17
17
  import * as path from 'node:path';
18
18
  import * as crypto from 'node:crypto';
19
19
  import { applyFix as applyFixHistory, fixAcceptanceRate } from '../posture/fix-history.js';
20
+ import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
20
21
  import { verifyLastScan } from '../posture/integrity.js';
21
22
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
22
23
  import { redactString, redactFinding } from './redact.js';
@@ -491,7 +492,7 @@ export const explain_finding = {
491
492
  // ─── apply_fix ───────────────────────────────────────────────────────────────
492
493
  export const apply_fix = {
493
494
  name: 'apply_fix',
494
- description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
495
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
495
496
  inputSchema: {
496
497
  type: 'object',
497
498
  additionalProperties: false,
@@ -499,10 +500,15 @@ export const apply_fix = {
499
500
  finding_id: { type: 'string', minLength: 1, maxLength: 256 },
500
501
  confirm: { type: 'boolean' },
501
502
  dry_run: { type: 'boolean' },
503
+ patch: {
504
+ type: 'object',
505
+ additionalProperties: { type: 'string', maxLength: 500_000 },
506
+ minProperties: 1, maxProperties: 8,
507
+ },
502
508
  },
503
509
  required: ['finding_id', 'confirm'],
504
510
  },
505
- async handler({ finding_id, confirm, dry_run = false }, ctx) {
511
+ async handler({ finding_id, confirm, dry_run = false, patch = null }, ctx) {
506
512
  if (confirm !== true) {
507
513
  return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
508
514
  }
@@ -515,6 +521,74 @@ export const apply_fix = {
515
521
  if (f._shadow === true) {
516
522
  return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
517
523
  }
524
+
525
+ // #3 — verifier-approved patch path. When the caller supplies `patch` (a
526
+ // files map, same shape as verify_fix), apply_fix re-runs the verifier
527
+ // INLINE and writes only if it passes: the original finding's stableId is
528
+ // gone, no new ≥medium finding was introduced, and lint is clean. This lets
529
+ // a deterministic OR LLM-synthesized patch be applied for the ~100% of
530
+ // findings that ship only a template/description (no stored fix.replacement).
531
+ // Security: all existing gates hold (confirm, last-scan HMAC, reserved
532
+ // paths, confinement, fix-history backup + attempt budget); the write is
533
+ // additionally gated on a FRESH verification, so a stale/forged patch can't
534
+ // slip through — there is no token to replay, the verify runs here and now.
535
+ if (patch && typeof patch === 'object' && Object.keys(patch).length) {
536
+ if (!f.stableId) {
537
+ return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a patch against it' };
538
+ }
539
+ const confinedAbs = {};
540
+ for (const [rel, content] of Object.entries(patch)) {
541
+ let abs;
542
+ try { abs = _confine(ctx.sessionRoot, rel, 'patch key'); }
543
+ catch (e) { return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` }; }
544
+ if (_isReservedWritePath(ctx.sessionRoot, abs)) {
545
+ return { _meta: META, applied: false, reason: `reserved path refused: ${rel}` };
546
+ }
547
+ confinedAbs[rel] = { abs, content: String(content) };
548
+ }
549
+ // Inline re-verify — the load-bearing gate. Must pass to write.
550
+ let verdict;
551
+ try {
552
+ const verifyFixCore = await getVerifyFixCore();
553
+ verdict = await verifyFixCore({
554
+ scanRoot: ctx.sessionRoot,
555
+ originalFindingStableId: f.stableId,
556
+ files: Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content])),
557
+ });
558
+ } catch (e) {
559
+ return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
560
+ }
561
+ if (!verdict.ok) {
562
+ return {
563
+ _meta: META, applied: false,
564
+ reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
565
+ verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok } },
566
+ };
567
+ }
568
+ if (dry_run) {
569
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
570
+ }
571
+ const written = [];
572
+ try {
573
+ for (const [rel, v] of Object.entries(confinedAbs)) {
574
+ const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
575
+ const entry = await applyFixHistory({
576
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
577
+ findingId: f.id, stableId: f.stableId, ruleId: f.rule || null, vuln: f.vuln || f.title || null,
578
+ });
579
+ written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
580
+ }
581
+ } catch (e) {
582
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
583
+ return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
584
+ }
585
+ throw e;
586
+ }
587
+ let acceptance = null;
588
+ try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
589
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
590
+ }
591
+
518
592
  if (typeof f.fix?.replacement !== 'string') {
519
593
  // Premortem #2: templates are patch-shaped text. Same reasoning as
520
594
  // the replacement path — do NOT pass through redactString here.
@@ -678,6 +752,20 @@ export const synthesize_fix = {
678
752
  locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
679
753
  }
680
754
  const oversized = touchedFiles > 3 || locDelta > 100;
755
+ // #1 — deterministic autofix: for classes with a safe context-independent
756
+ // swap (weak hash, TLS verify-off), materialize a full-file patch from the
757
+ // live file. The agent passes `autofix.patch` straight to apply_fix, which
758
+ // re-verifies it (rescan-clean + no new ≥medium + lint) before writing — so
759
+ // even a mis-attributed swap can't land a bad edit. No stored replacement,
760
+ // no per-finding bloat in last-scan.json.
761
+ let autofix = null;
762
+ if (!hasReplacement) {
763
+ try {
764
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
765
+ const det = synthesizeDeterministicPatch(f, fs.readFileSync(abs, 'utf8'));
766
+ if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
767
+ } catch { /* best-effort — no file / no rule → no autofix */ }
768
+ }
681
769
  // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
682
770
  // not a finding excerpt. Running it through redactString silently corrupts
683
771
  // valid patches whose content happens to match a secret-shape (e.g. a
@@ -694,9 +782,15 @@ export const synthesize_fix = {
694
782
  hasReplacement,
695
783
  replacement: hasReplacement ? fix.replacement : null,
696
784
  template: fix.code || null,
785
+ autofix,
786
+ // #15 — the regression test the scan annotator already generated for this
787
+ // finding (present when a PoC was built). Surfaced here so the fix flow
788
+ // writes the test alongside the patch; fix-verify-loop then runs it, so an
789
+ // applied fix ships with a test that fails pre-fix and passes post-fix.
790
+ regression_test: f.regression_test || null,
697
791
  remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
698
792
  patchBounds: { touchedFiles, locDelta, oversized },
699
- recommendsFixPlan: oversized && !hasReplacement,
793
+ recommendsFixPlan: oversized && !hasReplacement && !autofix,
700
794
  };
701
795
  },
702
796
  };
@@ -13,9 +13,11 @@ Annotators that run **after** every detector has emitted, plus state stores read
13
13
 
14
14
  **Risk amplification** — `epss.js`, `kev` (in `version.js`), `blast-radius.js`, `crown-jewels.js`, `exploitability.js`, `bounty-prediction.js`, `risk-in-dollars` (lives in `scripts/`, not here).
15
15
 
16
+ **Secrets lifecycle** — `secret-history.js` (git-history blob sweep for committed-then-removed secrets, behind `--secret-history`), `secret-live-check.js` (opt-in, offline-degrading live/dead/unknown labeling via a read-only provider "whoami"; behind `--validate-secrets`).
17
+
16
18
  **Production-posture ingest** — `auth-posture-import.js`, `network-policy-import.js`, `telemetry-ingest.js`, `waf-ingest.js`, `feature-flags.js`. These read customer-side YAML and convert to mitigation flags consumed by `mitigation-composite.js`.
17
19
 
18
- **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`.
20
+ **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
19
21
 
20
22
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
21
23
 
@@ -26,9 +26,11 @@ function money(n) {
26
26
 
27
27
  // Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
28
28
  const MODEL_RATES = {
29
- opus: { label: 'Opus 4.8', in: 5, out: 25 },
30
- sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
31
- haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
29
+ fable: { label: 'Fable 5', in: 10, out: 50 },
30
+ opus: { label: 'Opus 4.8', in: 5, out: 25 },
31
+ sonnet5: { label: 'Sonnet 5', in: 3, out: 15 },
32
+ sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
33
+ haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
32
34
  };
33
35
  const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
34
36
  const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
@@ -40,8 +42,9 @@ const TTL_MS = 5 * 60 * 1000;
40
42
  function rateFor(model) {
41
43
  if (typeof model !== 'string') return null;
42
44
  const s = model.toLowerCase();
45
+ if (s.includes('fable') || s.includes('mythos')) return MODEL_RATES.fable;
43
46
  if (s.includes('haiku')) return MODEL_RATES.haiku;
44
- if (s.includes('sonnet')) return MODEL_RATES.sonnet;
47
+ if (s.includes('sonnet')) return (s.includes('sonnet-5') || s.includes('sonnet 5')) ? MODEL_RATES.sonnet5 : MODEL_RATES.sonnet;
45
48
  if (s.includes('opus')) return MODEL_RATES.opus;
46
49
  return null;
47
50
  }
@@ -0,0 +1,65 @@
1
+ // Deterministic fix synthesis (#1) — for the narrow set of vulnerability classes
2
+ // where a context-INDEPENDENT literal swap is a safe, correct fix, produce a
3
+ // full-file replacement from the current file content. No LLM, no guessing, no
4
+ // per-finding bloat in last-scan.json (the patch is materialized on demand by
5
+ // synthesize_fix from the live file, not stored on every finding).
6
+ //
7
+ // Safety: every patch this produces is still gated by verify_fix before apply_fix
8
+ // writes it (original finding gone + no new ≥medium + lint clean). So a swap that
9
+ // a rule mis-attributed simply fails verification instead of landing a bad edit —
10
+ // this module widens the deterministic-fix surface without weakening the gate.
11
+ //
12
+ // Returns { patch: { [relFile]: newContent }, ruleId } or null when no
13
+ // deterministic fix applies to the finding.
14
+
15
+ const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
16
+ const PY_EXT = /\.py$/i;
17
+
18
+ // Each rule gates on the finding's cwe/family, then rewrites the whole-file
19
+ // content. transform() returns the new content, or null when nothing changed
20
+ // (e.g. the vulnerable token isn't literally present — then we don't claim a fix).
21
+ const RULES = [
22
+ {
23
+ id: 'weak-hash-sha256',
24
+ // md5 / sha1 → sha256. Every occurrence in the file is a weak hash, so
25
+ // swapping them all is safe; the verifier confirms the weak-hash finding is
26
+ // gone and nothing worse appeared.
27
+ applies: (f) => /CWE-(?:327|328|916)/.test(f.cwe || '') || /weak.?hash/i.test(f.family || ''),
28
+ transform: (content, file) => {
29
+ let out = content;
30
+ if (JS_EXT.test(file)) {
31
+ out = out.replace(/(\bcreateHash\s*\(\s*['"`])(?:md5|sha1)(['"`])/gi, '$1sha256$2');
32
+ } else if (PY_EXT.test(file)) {
33
+ out = out.replace(/\bhashlib\.(?:md5|sha1)\s*\(/g, 'hashlib.sha256(');
34
+ }
35
+ return out !== content ? out : null;
36
+ },
37
+ },
38
+ {
39
+ id: 'tls-verify-on',
40
+ // Disabled TLS verification → enabled. rejectUnauthorized:false → true (JS),
41
+ // verify=False → verify=True (Python requests).
42
+ applies: (f) => /CWE-295/.test(f.cwe || '') || /tls.?no.?verify|cert.?(?:none|verify)/i.test(f.family || ''),
43
+ transform: (content, file) => {
44
+ let out = content;
45
+ if (JS_EXT.test(file)) {
46
+ out = out.replace(/(\brejectUnauthorized\s*:\s*)false\b/g, '$1true');
47
+ } else if (PY_EXT.test(file)) {
48
+ out = out.replace(/(\bverify\s*=\s*)False\b/g, '$1True');
49
+ }
50
+ return out !== content ? out : null;
51
+ },
52
+ },
53
+ ];
54
+
55
+ export function synthesizeDeterministicPatch(finding, fileContent) {
56
+ if (!finding || typeof fileContent !== 'string' || !finding.file) return null;
57
+ for (const rule of RULES) {
58
+ try {
59
+ if (!rule.applies(finding)) continue;
60
+ const next = rule.transform(fileContent, finding.file);
61
+ if (next && next !== fileContent) return { patch: { [finding.file]: next }, ruleId: rule.id };
62
+ } catch { /* a single rule failing must never break synthesis */ }
63
+ }
64
+ return null;
65
+ }
@@ -15,9 +15,9 @@
15
15
 
16
16
  const TEMPLATES = {
17
17
  'sql-injection': (f) =>
18
- `An unauthenticated attacker sends a crafted request to ${_routeOf(f)} containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
18
+ `An unauthenticated attacker sends a crafted request containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
19
19
  'command-injection': (f) =>
20
- `The handler at ${f.file}:${f.line} passes user-controlled input to a shell-spawning function. An attacker can append shell metacharacters (";", "$(...)", backticks) to execute arbitrary commands as the application's UID. Typical impact: read of /etc/passwd, /proc/self/environ (env vars including secrets), outbound connections to attacker-controlled hosts (data exfil). On unprivileged containers the blast radius is limited to that container; on privileged or root-owned processes, the attacker can pivot to the host.`,
20
+ `The handler passes user-controlled input to a shell-spawning function. An attacker can append shell metacharacters (";", "$(...)", backticks) to execute arbitrary commands as the application's UID. Typical impact: read of /etc/passwd, /proc/self/environ (env vars including secrets), outbound connections to attacker-controlled hosts (data exfil). On unprivileged containers the blast radius is limited to that container; on privileged or root-owned processes, the attacker can pivot to the host.`,
21
21
  'xss': (f) =>
22
22
  `An attacker injects HTML/JS markup into user-controllable input. The server reflects (or stores) it without encoding, so when a victim browser renders the page, the attacker's script executes in the victim's session origin. Typical impact: session cookie theft, CSRF-bypass on internal endpoints, account takeover via API calls executed under the victim's auth. Cost: incident response, customer notification, potential data egress depending on what the victim's session can access.`,
23
23
  'ssrf': (f) =>
@@ -27,18 +27,23 @@ const TEMPLATES = {
27
27
  'code-injection': (f) =>
28
28
  `User input is fed into a code-evaluation function (eval, new Function, exec). An attacker supplies arbitrary code that executes in the application's runtime context, with full access to the application's data, env, and outbound network. Typical impact: equivalent to remote code execution; same recovery cost as command-injection.`,
29
29
  'csrf': (f) =>
30
- `The state-changing endpoint at ${f.file}:${f.line} doesn't validate that the request originated from your own application. An attacker hosts a page that issues a same-shape request from a logged-in victim's browser. Typical impact: state changes performed under the victim's identity — password change, money movement, role escalation. Cost: depends on what state can change; for billing endpoints, this is fraud-level.`,
30
+ `The state-changing endpoint doesn't validate that the request originated from your own application. An attacker hosts a page that issues a same-shape request from a logged-in victim's browser. Typical impact: state changes performed under the victim's identity — password change, money movement, role escalation. Cost: depends on what state can change; for billing endpoints, this is fraud-level.`,
31
31
  'open-redirect': (f) =>
32
32
  `The endpoint redirects to a URL the attacker controls. Used as part of phishing chains: victim clicks a legitimate-looking link to your domain, gets redirected to attacker.example, enters credentials thinking they're still on your site. Typical impact: phishing-amplified credential theft; reputational damage if your domain ends up on a phish-tracking list.`,
33
33
  'insecure-deserialization': (f) =>
34
34
  `The handler deserializes attacker-controlled bytes via pickle/yaml-load/Marshal. The deserialization callback invokes arbitrary code from class constructors / __reduce__ / __wakeup__. Typical impact: equivalent to remote code execution. Cost: full incident response, including investigating whether the attacker established persistence.`,
35
35
  'xxe': (f) =>
36
- `The XML parser at ${f.file}:${f.line} resolves external entities. An attacker submits XML referencing file:///etc/passwd or http://internal/. Typical impact: file disclosure, SSRF, blind out-of-band exfiltration of secrets. Cost: similar to SSRF + path-traversal combined.`,
36
+ `The XML parser resolves external entities. An attacker submits XML referencing file:///etc/passwd or http://internal/. Typical impact: file disclosure, SSRF, blind out-of-band exfiltration of secrets. Cost: similar to SSRF + path-traversal combined.`,
37
37
  };
38
38
 
39
39
  function _routeOf(f) {
40
- if (!f) return '<endpoint>';
41
- return `${f.file || '?'}:${f.line || '?'}`;
40
+ if (!f) return 'this endpoint';
41
+ // Narration may run before the line is finalised; fall back to the file alone
42
+ // (or a generic phrase) rather than emitting "file:undefined" / "file:?".
43
+ const line = Number(f.line) || Number(f.source?.line) || 0;
44
+ if (f.file && line) return `${f.file}:${line}`;
45
+ if (f.file) return f.file;
46
+ return 'this endpoint';
42
47
  }
43
48
 
44
49
  function _templateFor(f) {
@@ -65,7 +70,7 @@ async function _renderLlm(f) {
65
70
  Vuln: ${f.vuln}
66
71
  CWE: ${f.cwe}
67
72
  Severity: ${f.severity}
68
- Location: ${f.file}:${f.line}
73
+ Location: ${_routeOf(f)}
69
74
  Snippet: ${(f.snippet || '').slice(0, 200)}
70
75
 
71
76
  Write ONE paragraph (5-7 sentences) covering: (1) how an attacker reaches this code, (2) what they get if exploited, (3) typical recovery cost. Plain English, no marketing language, no emoji.`;
@@ -63,6 +63,31 @@ export function findingsExceedingSLA(findings, slaDays = null) {
63
63
  });
64
64
  }
65
65
 
66
+ // Median age (days) of the currently-open findings — a single-scan proxy for
67
+ // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
68
+ // of findings that were FIXED; this reports the open backlog's median age so a
69
+ // scan can show whether debt is getting older. Returns null on empty input.
70
+ // Local — surfaced only through renderSlaSummary (its sole consumer).
71
+ function medianOpenAgeDays(findings) {
72
+ const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
73
+ if (!ages.length) return null;
74
+ return ages[Math.floor(ages.length / 2)];
75
+ }
76
+
77
+ // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
78
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
79
+ // "is my security debt aging" readout that the vibecoder can act on.
80
+ export function renderSlaSummary(findings, slaDays = null) {
81
+ const breached = findingsExceedingSLA(findings || [], slaDays);
82
+ if (!breached.length) return null;
83
+ const bySev = {};
84
+ for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
85
+ const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
86
+ const median = medianOpenAgeDays(findings);
87
+ const ageNote = median != null ? ` (median open age ${median}d)` : '';
88
+ return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
89
+ }
90
+
66
91
  // Compute MTTR statistics from a series of saved scans (each with firstSeen/lastSeen).
67
92
  // Useful for trend reporting.
68
93
  export function computeMTTR(removedFindings) {