@clear-capabilities/agentic-security-scanner 0.127.0 → 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.
@@ -1 +1 @@
1
- 424585bd11f792465c48ad4fcf7bcb61be3b3351b80f3fdb500eb7f89cb6755c 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.127.0",
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",
@@ -61,13 +61,13 @@
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
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",
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
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
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",
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
@@ -174,6 +174,10 @@ import { applyLearnedCalibration } from './posture/triage-learning.js';
174
174
  import { annotateFormalVerification } from './dataflow/formal-verify.js';
175
175
  import { annotatePathFeasibility } from './dataflow/smt-feasibility.js';
176
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';
177
181
  import { computeAnalysisTiers, countUnmodeledSinkCandidates } from './posture/coverage-report.js';
178
182
  import { annotatePrivacyTaint, emitDpiaArtifact } from './dataflow/privacy-taint.js';
179
183
  import { buildThreatModel as buildAutoThreatModel, persistThreatModel as persistAutoThreatModel } from './posture/threat-model-auto.js';
@@ -7912,6 +7916,23 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7912
7916
  if (process.env.AGENTIC_SECURITY_NO_PROOF_GATE !== '1') {
7913
7917
  _runAnnotator("annotateProofGate", () => { annotateProofGate(finalFindings); });
7914
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
+ });
7915
7936
  // v3 next-gen: production-aware context ingest (Pillar 9). Must run BEFORE
7916
7937
  // the mitigation composite, persona prioritization, and final why-fired
7917
7938
  // record so those see the demotion signals.
@@ -8542,7 +8563,12 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
8542
8563
  try { _analysisTier = computeAnalysisTiers(Object.keys(fc)); } catch {}
8543
8564
  try { _unmodeledSinks = countUnmodeledSinkCandidates(fc, finalFindings); } catch {}
8544
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}};
8545
- 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};}
8546
8572
 
8547
8573
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8548
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/mcp/tools.js CHANGED
@@ -549,12 +549,23 @@ export const apply_fix = {
549
549
  // Inline re-verify — the load-bearing gate. Must pass to write.
550
550
  let verdict;
551
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
- });
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
+ }
558
569
  } catch (e) {
559
570
  return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
560
571
  }
@@ -21,6 +21,13 @@ Annotators that run **after** every detector has emitted, plus state stores read
21
21
 
22
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`.
23
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
+
24
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.
25
32
 
26
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`.
@@ -0,0 +1,248 @@
1
+ // Attack-surface completeness inventory (addition #2).
2
+ //
3
+ // Enumerates every attacker-reachable entry point across a codebase and
4
+ // assigns each a disposition, producing an auditable coverage ledger. The
5
+ // point is completeness: rather than only reporting where a finding fired,
6
+ // this lists every surface an attacker can *reach* (HTTP routes, queue
7
+ // consumers, cron jobs, CLI arg parsing, environment reads, file uploads,
8
+ // webhooks) and states, for each, whether it was traced clean, has an open
9
+ // finding, or otherwise. A reviewer can then audit the coverage rather than
10
+ // trust that "no finding == safe."
11
+ //
12
+ // Entry-point types (7):
13
+ // http — inbound HTTP routes (from opts.routes; one entry per route)
14
+ // queue — message-queue consumers (Kafka / SQS / RabbitMQ / pub-sub)
15
+ // cron — scheduled jobs (@Scheduled, cron.schedule, setInterval, celery)
16
+ // cli — command-line argument parsing (argv / argparse / commander / …)
17
+ // env — environment-variable reads (process.env / getenv)
18
+ // upload — file-upload sinks (multer / req.files / MultipartFile)
19
+ // webhook — inbound webhook handlers (route path or /webhook literal)
20
+ //
21
+ // Granularity: HTTP/webhook routes are enumerated per route. The regex-
22
+ // discovered surfaces are enumerated per (type, file) — a source file that
23
+ // exposes a surface is counted once for that surface, recording the first
24
+ // matching line. This avoids double-counting an import line plus its use
25
+ // site (e.g. `import multer` + `multer(...)`) while still distinguishing
26
+ // distinct surface types that share a file.
27
+ //
28
+ // No throwing: every public entry degrades to an empty/zeroed ledger.
29
+
30
+ // ── Entry-point type order (fixed — drives byType key order) ────────────────
31
+ const ENTRY_TYPES = ['http', 'queue', 'cron', 'cli', 'env', 'upload', 'webhook'];
32
+
33
+ // ── Regex-discovered surface patterns ──────────────────────────────────────
34
+ // Non-global regexes (no /g) so repeated .test()/.exec() calls are stateless.
35
+ // Ordered roughly most-specific → most-generic within each type.
36
+ const SURFACE_PATTERNS = {
37
+ queue: [
38
+ /@KafkaListener\b/,
39
+ /@SqsListener\b/,
40
+ /@RabbitListener\b/,
41
+ /\b(?:sqs|kafka|rabbit)\w*\s*\.\s*(?:consume|subscribe|receiveMessage|poll|on)\b/i,
42
+ /new\s+(?:Kafka)?Consumer\s*\(/,
43
+ /\.consume\s*\(/,
44
+ /\.subscribe\s*\(/,
45
+ ],
46
+ cron: [
47
+ /@Scheduled\b/,
48
+ /cron\.schedule\s*\(/,
49
+ /\bnode-cron\b/,
50
+ /@shared_task\b/,
51
+ /@periodic_task\b/,
52
+ /@(?:app\.)?task\b/, // celery
53
+ /setInterval\s*\(/, // interval used as a recurring job
54
+ ],
55
+ cli: [
56
+ /process\.argv\b/,
57
+ /\bargparse\b/,
58
+ /\bcommander\b/,
59
+ /\byargs\b/,
60
+ /\bcobra\.Command\b/,
61
+ /\bflag\.Parse\s*\(/,
62
+ ],
63
+ env: [
64
+ /process\.env\.\w+/,
65
+ /os\.getenv\s*\(/,
66
+ /System\.getenv\s*\(/,
67
+ ],
68
+ upload: [
69
+ /\bmulter\b/,
70
+ /\breq\.files?\b/,
71
+ /\brequest\.files?\b/,
72
+ /multipart\/form-data/i,
73
+ /\bMultipartFile\b/,
74
+ ],
75
+ webhook: [
76
+ /['"`][^'"`]*\/webhook[^'"`]*['"`]/i, // a "/webhook…" string literal
77
+ ],
78
+ };
79
+
80
+ // Auth tokens — presence near an entry point in the same file promotes its
81
+ // trust boundary from unauthenticated → authenticated. `\bauth\b` matches the
82
+ // bare token only (not "author" / "oauth", which retain their word chars).
83
+ const AUTH_TOKEN = /(?:@PreAuthorize|requireAuth|require_auth|login_required|isAuthenticated|authenticate\w*|authorize\w*|authMiddleware|authGuard|\bauth\b)/i;
84
+
85
+ const AUTH_WINDOW = 8; // lines above/below the entry point to scan for auth
86
+
87
+ // ── Input normalization ─────────────────────────────────────────────────────
88
+ // Accept either a Map<filepath,string> or a plain object {path: source}.
89
+ function _entries(fileContents) {
90
+ if (!fileContents) return [];
91
+ if (fileContents instanceof Map) {
92
+ return [...fileContents.entries()].filter(([, v]) => typeof v === 'string');
93
+ }
94
+ if (typeof fileContents === 'object') {
95
+ return Object.entries(fileContents).filter(([, v]) => typeof v === 'string');
96
+ }
97
+ return [];
98
+ }
99
+
100
+ function _emptyCoverage() {
101
+ const byType = {};
102
+ for (const t of ENTRY_TYPES) byType[t] = 0;
103
+ return { total: 0, byType, tracedSafe: 0, finding: 0, notReachable: 0, noInput: 0 };
104
+ }
105
+
106
+ function _authNear(lines, line) {
107
+ const lo = Math.max(0, line - 1 - AUTH_WINDOW);
108
+ const hi = Math.min(lines.length, line + AUTH_WINDOW);
109
+ return AUTH_TOKEN.test(lines.slice(lo, hi).join('\n'));
110
+ }
111
+
112
+ function _routeHasInput(route) {
113
+ if (Array.isArray(route.params) && route.params.length) return true;
114
+ const p = typeof route.path === 'string' ? route.path : '';
115
+ // :id (Express/Koa), {id} (FastAPI/Spring), <id> (Flask/Django) → has input.
116
+ return /[:{<]\w/.test(p);
117
+ }
118
+
119
+ function _clip(s, n = 80) {
120
+ const str = String(s == null ? '' : s).trim();
121
+ return str.length > n ? str.slice(0, n) : str;
122
+ }
123
+
124
+ // ── Core ─────────────────────────────────────────────────────────────────────
125
+
126
+ export function buildEntrypointInventory(fileContents, opts = {}) {
127
+ const coverage = _emptyCoverage();
128
+ const entrypoints = [];
129
+ try {
130
+ const routes = Array.isArray(opts && opts.routes) ? opts.routes : [];
131
+ const findings = Array.isArray(opts && opts.findings) ? opts.findings : [];
132
+ const findingFiles = new Set(
133
+ findings.map(f => (f && typeof f.file === 'string' ? f.file : null)).filter(Boolean),
134
+ );
135
+
136
+ // 1) HTTP / webhook entry points — one per route.
137
+ for (const route of routes) {
138
+ if (!route || typeof route !== 'object') continue;
139
+ const file = typeof route.file === 'string' && route.file ? route.file : '(unknown)';
140
+ const line = Number.isInteger(route.line) ? route.line : 0;
141
+ const method = typeof route.method === 'string' ? route.method : 'GET';
142
+ const path = typeof route.path === 'string' ? route.path : '';
143
+ const isWebhook = /webhook/i.test(path) || /webhook/i.test(String(route.handler || ''));
144
+ const type = isWebhook ? 'webhook' : 'http';
145
+ const name = _clip(route.handler || `${method} ${path}`.trim() || type);
146
+
147
+ // Trust: an authenticated route (hasAuth) or a nearby auth token wins.
148
+ let trust = route.hasAuth === true ? 'authenticated' : 'unauthenticated';
149
+ if (trust === 'unauthenticated' && fileContents) {
150
+ const src = _srcOf(fileContents, file);
151
+ if (src && _authNear(src.split(/\r?\n/), line)) trust = 'authenticated';
152
+ }
153
+
154
+ let disposition;
155
+ if (findingFiles.has(file)) disposition = 'finding';
156
+ else if (type === 'http' && !_routeHasInput(route)) disposition = 'no-input';
157
+ else disposition = 'traced-safe';
158
+
159
+ entrypoints.push({ type, file, line, name, trust, disposition });
160
+ }
161
+
162
+ // 2) Regex-discovered surfaces — one per (type, file), first matching line.
163
+ for (const [file, source] of _entries(fileContents)) {
164
+ const lines = source.split(/\r?\n/);
165
+ for (const type of ENTRY_TYPES) {
166
+ const pats = SURFACE_PATTERNS[type];
167
+ if (!pats) continue; // http has no file-scan pattern (routes-only)
168
+ let hitLine = -1;
169
+ let hitText = '';
170
+ outer:
171
+ for (let i = 0; i < lines.length; i++) {
172
+ for (const re of pats) {
173
+ const m = re.exec(lines[i]);
174
+ if (m) { hitLine = i + 1; hitText = _clip(m[0] || type); break outer; }
175
+ }
176
+ }
177
+ if (hitLine < 0) continue;
178
+
179
+ const trust = _authNear(lines, hitLine) ? 'authenticated' : 'unauthenticated';
180
+ // Regex surfaces inherently carry attacker-controlled input (a queue
181
+ // message, an env value, a CLI arg, an upload). Only HTTP routes get
182
+ // the optional 'no-input' disposition.
183
+ const disposition = findingFiles.has(file) ? 'finding' : 'traced-safe';
184
+ entrypoints.push({ type, file, line: hitLine, name: hitText || type, trust, disposition });
185
+ }
186
+ }
187
+
188
+ // 3) Roll up the coverage ledger.
189
+ coverage.total = entrypoints.length;
190
+ for (const e of entrypoints) {
191
+ if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
192
+ if (e.disposition === 'finding') coverage.finding++;
193
+ else if (e.disposition === 'not-reachable') coverage.notReachable++;
194
+ else if (e.disposition === 'no-input') coverage.noInput++;
195
+ else coverage.tracedSafe++;
196
+ }
197
+ } catch (_) {
198
+ // Degrade to whatever we accumulated before the error (or empty).
199
+ return { entrypoints, coverage: _recount(entrypoints) };
200
+ }
201
+ return { entrypoints, coverage };
202
+ }
203
+
204
+ // Defensive re-count used only on the error path.
205
+ function _recount(entrypoints) {
206
+ const coverage = _emptyCoverage();
207
+ coverage.total = entrypoints.length;
208
+ for (const e of entrypoints) {
209
+ if (coverage.byType[e.type] != null) coverage.byType[e.type]++;
210
+ if (e.disposition === 'finding') coverage.finding++;
211
+ else if (e.disposition === 'not-reachable') coverage.notReachable++;
212
+ else if (e.disposition === 'no-input') coverage.noInput++;
213
+ else coverage.tracedSafe++;
214
+ }
215
+ return coverage;
216
+ }
217
+
218
+ function _srcOf(fileContents, file) {
219
+ if (!fileContents) return null;
220
+ if (fileContents instanceof Map) {
221
+ const v = fileContents.get(file);
222
+ return typeof v === 'string' ? v : null;
223
+ }
224
+ const v = fileContents[file];
225
+ return typeof v === 'string' ? v : null;
226
+ }
227
+
228
+ // ── Engine wiring helper ─────────────────────────────────────────────────────
229
+ // Annotates a scan object in place with scan.entrypointInventory. Never throws;
230
+ // absent inputs yield an empty ledger.
231
+ export function annotateEntrypointCoverage(scan) {
232
+ if (!scan || typeof scan !== 'object') return scan;
233
+ try {
234
+ const fileContents = scan.fileContents || scan._fileContents || null;
235
+ const routes = Array.isArray(scan.routes) ? scan.routes : [];
236
+ const findings = Array.isArray(scan.findings) ? scan.findings : [];
237
+ if (!fileContents && routes.length === 0) {
238
+ scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
239
+ return scan;
240
+ }
241
+ scan.entrypointInventory = buildEntrypointInventory(fileContents || {}, { routes, findings });
242
+ } catch (_) {
243
+ scan.entrypointInventory = { entrypoints: [], coverage: _emptyCoverage() };
244
+ }
245
+ return scan;
246
+ }
247
+
248
+ export const _internals = { SURFACE_PATTERNS, ENTRY_TYPES, AUTH_TOKEN, _entries };
@@ -0,0 +1,121 @@
1
+ // Addition #1 — Default falsification pass ("prove it can't be blocked, or demote").
2
+ //
3
+ // For each taint-style finding we actively try to DISPROVE it: locate a
4
+ // context-matched control (a sanitizer whose shape actually neutralizes THIS
5
+ // CWE family) on the path between source and sink. A finding that is blocked by
6
+ // such a control is "falsified" — demoted and quarantined. A finding with no
7
+ // blocking control "survives" and stands.
8
+ //
9
+ // This is recall-preserving, exactly like `dataflow/proof-gate.js`: a falsified
10
+ // finding is DEMOTED (confidence + tiers) and flagged `quarantined`, never
11
+ // removed and never severity-touched. Genuine vulnerabilities have no valid
12
+ // control on the path, so they survive — the corpus `pre:TP` fixtures stay TP.
13
+ //
14
+ // An OPTIONAL LLM tier (`opts.llmReview`, wired only when an LLM endpoint is
15
+ // configured) argues the opposing case over survivors; it is never required and
16
+ // the deterministic core runs fully offline.
17
+
18
+ import { isValidSanitizerFor } from '../dataflow/sanitizer-proof.js';
19
+
20
+ const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
21
+ const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
22
+
23
+ function _dropTier(tier) {
24
+ const i = TIERS.indexOf(tier);
25
+ if (i <= 0) return tier; // unknown or already lowest → unchanged
26
+ return TIERS[i - 1];
27
+ }
28
+
29
+ function _fileText(fileContents, file) {
30
+ if (!fileContents || !file) return '';
31
+ if (fileContents instanceof Map) return fileContents.get(file) || '';
32
+ return fileContents[file] || '';
33
+ }
34
+
35
+ // Reconstruct the path window: the source line, the sink line, and the lines
36
+ // between/around the sink, plus whatever snippets the finding already carries.
37
+ function _pathWindow(finding, fileContents) {
38
+ const parts = [];
39
+ if (finding.source?.snippet) parts.push(String(finding.source.snippet));
40
+ if (finding.sink?.snippet) parts.push(String(finding.sink.snippet));
41
+ const text = _fileText(fileContents, finding.file);
42
+ if (text) {
43
+ const lines = text.split('\n');
44
+ const sinkLine = Number(finding.sink?.line) || 0;
45
+ const srcLine = Number(finding.source?.line) || 0;
46
+ const lo = Math.max(0, Math.min(sinkLine, srcLine) - 3);
47
+ const hi = Math.min(lines.length, Math.max(sinkLine, srcLine) + 3);
48
+ for (let i = lo; i < hi; i++) parts.push(lines[i]);
49
+ }
50
+ return parts.join('\n');
51
+ }
52
+
53
+ /**
54
+ * Pure classifier. Returns `{ verdict, reasons }` with verdict ∈
55
+ * 'blocked' — a context-matched control for this CWE family sits on the path
56
+ * 'survived' — no blocking control found; the finding stands
57
+ * 'unproven' — not enough context to attempt falsification
58
+ */
59
+ export function classifyFinding(finding, fileContents) {
60
+ if (!finding || !finding.cwe || !finding.source || !finding.sink) {
61
+ return { verdict: 'unproven', reasons: ['not a taint-style finding'] };
62
+ }
63
+ // A sanitizer that doesn't match the sink context does NOT block the flow —
64
+ // the finding survives (this is a real bug, not a mitigation).
65
+ if (finding.sanitizerMismatch === true) {
66
+ return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
67
+ }
68
+ const window = _pathWindow(finding, fileContents);
69
+ if (!window || !window.trim()) {
70
+ return { verdict: 'unproven', reasons: ['no source context available to attempt falsification'] };
71
+ }
72
+ const v = isValidSanitizerFor(window, finding.cwe);
73
+ if (v.trusted) {
74
+ return { verdict: 'blocked', reasons: [`context-matched control on path — ${v.reason}`] };
75
+ }
76
+ return { verdict: 'survived', reasons: ['no context-matched control found between source and sink'] };
77
+ }
78
+
79
+ /**
80
+ * Default-on annotator. Adds `finding.falsification = { verdict, reasons }` to
81
+ * every taint-style finding; demotes + quarantines the ones falsified as blocked.
82
+ * NEVER removes a finding and NEVER mutates severity (recall-preserving).
83
+ *
84
+ * @param opts.llmReview optional (survivor) => { verdict, reason } — the LLM tier.
85
+ * Wired only when an LLM endpoint is configured; run over
86
+ * survivors, and its result is attached at .falsification.llm.
87
+ */
88
+ export function annotateFalsification(findings, fileContents, opts = {}) {
89
+ if (!Array.isArray(findings)) return findings;
90
+ const survivors = [];
91
+ for (const f of findings) {
92
+ if (!f || !f.source || !f.sink || !f.cwe) continue; // only taint-style findings
93
+ let res;
94
+ try { res = classifyFinding(f, fileContents); }
95
+ catch { res = { verdict: 'unproven', reasons: ['classification error'] }; }
96
+ f.falsification = { verdict: res.verdict, reasons: res.reasons };
97
+
98
+ if (res.verdict === 'blocked') {
99
+ f.quarantined = true;
100
+ if (typeof f.confidence === 'number') {
101
+ f.confidence = Math.max(0, Math.round(f.confidence * DEMOTE_FACTOR * 1000) / 1000);
102
+ }
103
+ if (f.confidenceTier) f.confidenceTier = _dropTier(f.confidenceTier);
104
+ if (f.exploitabilityTier) f.exploitabilityTier = _dropTier(f.exploitabilityTier);
105
+ // severity intentionally untouched.
106
+ } else if (res.verdict === 'survived') {
107
+ survivors.push(f);
108
+ }
109
+ }
110
+
111
+ // Optional LLM tier — only over survivors, only when a reviewer is supplied.
112
+ if (typeof opts.llmReview === 'function') {
113
+ for (const f of survivors) {
114
+ try {
115
+ const llm = opts.llmReview(f);
116
+ if (llm) f.falsification.llm = llm;
117
+ } catch { /* the LLM tier is advisory; never let it break the scan */ }
118
+ }
119
+ }
120
+ return findings;
121
+ }