@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.
- package/CHANGELOG.md +60 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +525 -0
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +18 -7
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/agentic-security.mjs +1 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +27 -1
- package/src/integrations/tickets.js +9 -3
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +7 -0
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/falsification.js +121 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +18 -3
- package/src/posture/model-routing.js +126 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/pr-comment.js +3 -1
- package/src/util/untrusted.js +148 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
}
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -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
|
+
}
|