@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
@@ -1 +1 @@
1
- c162f0538727ff2cce518e33cd577b95521e9a1926abe63d7ed89afc3d21c21c agentic-security.mjs
1
+ 2d630902bbb9bebbd5557bce1c7db106cd81fdf975cf18c5273b1e46716103a7 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.1",
3
+ "version": "0.128.1",
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,14 +60,14 @@
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/falsification.test.js test/fix-honesty-gate.test.js test/model-routing.test.js test/root-cause-sweep.test.js test/entrypoint-inventory.test.js test/untrusted.test.js test/agent-hardening.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",
70
- "test:eval": "node --test test/independent-eval.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 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
+ "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.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",
73
73
  "smoke": "node bin/agentic-security.js scan test/fixtures/vulnerable-js",
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';
@@ -172,6 +174,10 @@ import { applyLearnedCalibration } from './posture/triage-learning.js';
172
174
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
173
175
  import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
174
176
  import { annotateProofGate } from './dataflow/proof-gate.js';
177
+ import { annotateFalsification } from './posture/falsification.js';
178
+ import { routeModelForFinding } from './posture/model-routing.js';
179
+ import { buildEntrypointInventory } from './posture/entrypoint-inventory.js';
180
+ import { sweepRootCauses } from './posture/root-cause-sweep.js';
175
181
  import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
176
182
  import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
177
183
  import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
@@ -7396,6 +7402,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7396
7402
  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
7403
  aF.push(...scanLLM(p,c));
7398
7404
  aF.push(...scanLLMOwasp(p,c));
7405
+ aF.push(...scanLlmCost(p,c));
7399
7406
  aLogic.push(...scanBusinessLogic(p,c));
7400
7407
  aF.push(...scanPipeline(p,c));
7401
7408
  aF.push(...scanContainer(p,c));
@@ -7429,6 +7436,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7429
7436
  aF.push(...scanJavaDeserialization(p,c));
7430
7437
  aF.push(...scanJwtExp(p,c));
7431
7438
  aF.push(...scanZipSlip(p,c));
7439
+ aF.push(...scanFileUpload(p,c));
7432
7440
  aF.push(...scanHostHeader(p,c));
7433
7441
  aF.push(...scanPythonSinks(p,c));
7434
7442
  aF.push(...scanCSharp(p,c));
@@ -7908,6 +7916,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7908
7916
  if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
7909
7917
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
7910
7918
  }
7919
+ // Addition #1 — default falsification pass. Actively tries to DISPROVE each
7920
+ // taint-style finding by locating a context-matched control on the path, and
7921
+ // demotes + quarantines the ones it can block. Recall-preserving (never
7922
+ // removes a finding, never touches severity — like the proof gate). Runs
7923
+ // AFTER proof-gate so it layers on the same demotion channel. Deterministic by
7924
+ // default; the LLM tier is only wired when an endpoint is configured. Opt out
7925
+ // with AGENTIC_SECURITY_NO_FALSIFICATION=1.
7926
+ if (process.env.AGENTIC_SECURITY_NO_FALSIFICATION !== '1') {
7927
+ _runAnnotator("annotateFalsification", () => { annotateFalsification(finalFindings, fc); });
7928
+ }
7929
+ // Addition #5 — capability-based model routing. Stamp each finding with the
7930
+ // model tier a cost-sensitive fixer/triager/PoC subagent should be dispatched
7931
+ // on for THIS vuln class (crypto/auth/critical → strongest; injection → mid;
7932
+ // low-sev hardening → cheapest). Advisory metadata consumed at dispatch time.
7933
+ _runAnnotator("annotateDispatchModel", () => {
7934
+ for (const f of finalFindings) { try { f.dispatchModel = routeModelForFinding(f).model; } catch { /* advisory only */ } }
7935
+ });
7911
7936
  // v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
7912
7937
  // the mitigation composite, persona prioritization, and final why-fired
7913
7938
  // record so those see the demotion signals.
@@ -8538,7 +8563,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8538
8563
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8539
8564
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8540
8565
  const _scanMeta={filesScanned:files.length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings.sort((a,b)=>b.ms-a.ms).slice(0,20),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length}};
8541
- return{routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8566
+ // Addition #2 — attack-surface completeness inventory (entry points → dispositions).
8567
+ let _entrypointInventory = {}; try { _entrypointInventory = buildEntrypointInventory(fc, { routes: aR, findings: finalFindings }); } catch { _entrypointInventory = {}; }
8568
+ // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8569
+ // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8570
+ let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
8571
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8542
8572
 
8543
8573
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8544
8574
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import * as cp from 'node:child_process';
19
19
  import { buildJiraIssue } from './index.js';
20
+ import { escapeMarkdown } from '../util/untrusted.js';
20
21
 
21
22
  function statePath(scanRoot) {
22
23
  return path.join(scanRoot, '.agentic-security', 'tickets.json');
@@ -32,7 +33,10 @@ function writeState(scanRoot, state) {
32
33
  }
33
34
 
34
35
  function findingTitle(f) {
35
- return `[${(f.severity || 'medium').toUpperCase()}] ${f.vuln || f.title || 'security finding'} at ${f.file}:${f.line}`;
36
+ // vuln/title are lifted from the (untrusted) scanned code escape before
37
+ // they land in an issue title. See docs/AGENT_THREAT_MODEL.md path #1.
38
+ const label = escapeMarkdown(f.vuln) || escapeMarkdown(f.title) || 'security finding';
39
+ return `[${(f.severity || 'medium').toUpperCase()}] ${label} at ${f.file}:${f.line}`;
36
40
  }
37
41
  function findingBody(f) {
38
42
  const br = f.blastRadius;
@@ -43,9 +47,11 @@ function findingBody(f) {
43
47
  f.cwe ? `**CWE:** ${f.cwe}` : null,
44
48
  f.epss != null ? `**EPSS:** ${f.epss.toFixed(4)} (percentile ${(f.epssPercentile * 100).toFixed(1)}%)` : null,
45
49
  exploited,
46
- f.description ? `\n${f.description}` : null,
50
+ f.description ? `\n${escapeMarkdown(f.description)}` : null,
47
51
  br?.narrative ? `\n**Blast radius:** ${br.narrative}` : null,
48
- f.snippet ? `\n\`\`\`\n${f.snippet}\n\`\`\`` : null,
52
+ // snippet is attacker-authored code; escape so a crafted ``` fence or
53
+ // <img>/[x](url) inside it cannot break out of the code block.
54
+ f.snippet ? `\n\`\`\`\n${escapeMarkdown(f.snippet)}\n\`\`\`` : null,
49
55
  f.remediation ? `\n**Remediation:** ${f.remediation}` : null,
50
56
  `\n---\n_Surfaced by agentic-security · finding id: ${f.id}_`,
51
57
  ].filter(Boolean).join('\n');
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,85 @@ 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 _files = Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content]));
553
+ if (process.env.AGENTIC_SECURITY_FIX_RUN_TESTS === '1') {
554
+ // Addition #7 — connect the closed-loop verifier: add the project test
555
+ // suite as a fourth verification leg (scan + lint + tests). Opt-in
556
+ // because many repos have no runner and we must not fail-closed by
557
+ // default. Normalized to the scan+lint verdict shape used below.
558
+ const { verifyFixWithTests } = await import('../posture/fix-verify-loop.js');
559
+ const t = await verifyFixWithTests({ scanRoot: ctx.sessionRoot, originalFindingStableId: f.stableId, files: _files });
560
+ verdict = { ok: t.ok, summary: t.summary, rescan: t.legs?.scan?.detail, lint: t.legs?.lint?.detail, tests: t.legs?.tests, testVerdict: t.verdict };
561
+ } else {
562
+ const verifyFixCore = await getVerifyFixCore();
563
+ verdict = await verifyFixCore({
564
+ scanRoot: ctx.sessionRoot,
565
+ originalFindingStableId: f.stableId,
566
+ files: _files,
567
+ });
568
+ }
569
+ } catch (e) {
570
+ return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
571
+ }
572
+ if (!verdict.ok) {
573
+ return {
574
+ _meta: META, applied: false,
575
+ reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
576
+ verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok } },
577
+ };
578
+ }
579
+ if (dry_run) {
580
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
581
+ }
582
+ const written = [];
583
+ try {
584
+ for (const [rel, v] of Object.entries(confinedAbs)) {
585
+ const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
586
+ const entry = await applyFixHistory({
587
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
588
+ findingId: f.id, stableId: f.stableId, ruleId: f.rule || null, vuln: f.vuln || f.title || null,
589
+ });
590
+ written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
591
+ }
592
+ } catch (e) {
593
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
594
+ return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
595
+ }
596
+ throw e;
597
+ }
598
+ let acceptance = null;
599
+ try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
600
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
601
+ }
602
+
518
603
  if (typeof f.fix?.replacement !== 'string') {
519
604
  // Premortem #2: templates are patch-shaped text. Same reasoning as
520
605
  // the replacement path — do NOT pass through redactString here.
@@ -678,6 +763,20 @@ export const synthesize_fix = {
678
763
  locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
679
764
  }
680
765
  const oversized = touchedFiles > 3 || locDelta > 100;
766
+ // #1 — deterministic autofix: for classes with a safe context-independent
767
+ // swap (weak hash, TLS verify-off), materialize a full-file patch from the
768
+ // live file. The agent passes `autofix.patch` straight to apply_fix, which
769
+ // re-verifies it (rescan-clean + no new ≥medium + lint) before writing — so
770
+ // even a mis-attributed swap can't land a bad edit. No stored replacement,
771
+ // no per-finding bloat in last-scan.json.
772
+ let autofix = null;
773
+ if (!hasReplacement) {
774
+ try {
775
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
776
+ const det = synthesizeDeterministicPatch(f, fs.readFileSync(abs, 'utf8'));
777
+ if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
778
+ } catch { /* best-effort — no file / no rule → no autofix */ }
779
+ }
681
780
  // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
682
781
  // not a finding excerpt. Running it through redactString silently corrupts
683
782
  // valid patches whose content happens to match a secret-shape (e.g. a
@@ -694,9 +793,15 @@ export const synthesize_fix = {
694
793
  hasReplacement,
695
794
  replacement: hasReplacement ? fix.replacement : null,
696
795
  template: fix.code || null,
796
+ autofix,
797
+ // #15 — the regression test the scan annotator already generated for this
798
+ // finding (present when a PoC was built). Surfaced here so the fix flow
799
+ // writes the test alongside the patch; fix-verify-loop then runs it, so an
800
+ // applied fix ships with a test that fails pre-fix and passes post-fix.
801
+ regression_test: f.regression_test || null,
697
802
  remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
698
803
  patchBounds: { touchedFiles, locDelta, oversized },
699
- recommendsFixPlan: oversized && !hasReplacement,
804
+ recommendsFixPlan: oversized && !hasReplacement && !autofix,
700
805
  };
701
806
  },
702
807
  };
@@ -13,12 +13,21 @@ 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
 
24
+ **Methodology additions (`docs/AGENTIC_METHODOLOGY_PRD.md`)** — default-on annotators/artifacts that layer the agentic-hunter methodology on the deterministic engine:
25
+ - `falsification.js` — default falsification pass. For each taint-style finding, tries to DISPROVE it (locate a context-matched control on the path, reusing `dataflow/sanitizer-proof.js`'s shape rules read-only); a blocked finding is demoted + `quarantined`, never removed and never severity-touched (recall-preserving, like `proof-gate`). Wired after `annotateProofGate`. Opt out: `AGENTIC_SECURITY_NO_FALSIFICATION=1`. Optional LLM tier over survivors when an endpoint is configured.
26
+ - `entrypoint-inventory.js` — attack-surface completeness ledger. Enumerates every entry point (HTTP/queue/cron/CLI/env/upload/webhook) with a disposition each; on `scan.entrypointInventory`.
27
+ - `root-cause-sweep.js` — from confirmed findings, finds sibling instances detectors missed with total-count accounting (`found === candidates + mitigated`); on `scan.rootCauseSweep`.
28
+ - `model-routing.js` — capability-based CWE/severity→model policy; stamps `finding.dispatchModel` (strongest for crypto/auth/critical, mid for injection, cheapest for low-sev hardening) for cost-sensitive subagent dispatch.
29
+ - `fix-honesty-gate.js` — deterministic honesty gates on fix output: a residual-risk hand-wave guard, a cited-file:line requirement for any FP/safe verdict, and FULL/MITIGATION/WORKAROUND completeness tiers. Consumed by `fix-verify.js` when the caller supplies fix metadata; the closed-loop test leg (`fix-verify-loop.js`) is wired into `mcp/apply_fix` behind `AGENTIC_SECURITY_FIX_RUN_TESTS=1`.
30
+
22
31
  **Integrity + signing** — `integrity.js` (per-install HMAC for `last-scan.json`), `rule-pack-signing.js`. The HMAC key lives at `$XDG_CONFIG_HOME/agentic-security/scan-key`; override via `$AGENTIC_SECURITY_HMAC_KEY`. Premortem-derived; do not regress to hostname-derived.
23
32
 
24
33
  **Rule lifecycle** — `custom-rules.js` (YAML pattern DSL), `rule-overrides.js` (`disable:` gated on signature), `rule-packs.js`, `rule-synthesis.js` (proposes suppressions from triage feedback), `ruleset-version.js`.
@@ -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
+ }