@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9
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 +236 -0
- package/bin/agentic-security.js +186 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +183 -14
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +18 -10
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +202 -42
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +155 -14
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
|
@@ -104,12 +104,25 @@ export function ensureKeyPair(dir = keyDir()) {
|
|
|
104
104
|
return { privateKeyPem, publicKeyPem, created: true, ...p };
|
|
105
105
|
} catch (e) {
|
|
106
106
|
if (e.code !== 'EEXIST') throw e;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
// We lost the race on the private key — someone else's 'wx' won.
|
|
108
|
+
// Their public-key write follows immediately after their private-key
|
|
109
|
+
// write but is not itself atomic with it, so it may not have landed
|
|
110
|
+
// yet: a bare read here would throw an uncaught ENOENT on a genuinely
|
|
111
|
+
// transient state, not a real error. Retry briefly rather than crash.
|
|
112
|
+
const sleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
113
|
+
for (let attempt = 0; ; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
return {
|
|
116
|
+
privateKeyPem: fs.readFileSync(p.privateKey, 'utf8'),
|
|
117
|
+
publicKeyPem: fs.readFileSync(p.publicKey, 'utf8'),
|
|
118
|
+
created: false,
|
|
119
|
+
...p,
|
|
120
|
+
};
|
|
121
|
+
} catch (readErr) {
|
|
122
|
+
if (readErr.code !== 'ENOENT' || attempt >= 50) throw readErr;
|
|
123
|
+
Atomics.wait(sleepBuf, 0, 0, 10); // 10ms; ~500ms total budget
|
|
124
|
+
}
|
|
125
|
+
}
|
|
113
126
|
}
|
|
114
127
|
}
|
|
115
128
|
|
|
@@ -227,9 +240,26 @@ export function signEvidenceBundle(bundle, privateKeyPem) {
|
|
|
227
240
|
* Returns {ok, reason}. Never throws — a malformed bundle from an untrusted
|
|
228
241
|
* source is an expected input, not an exceptional one.
|
|
229
242
|
*/
|
|
243
|
+
// The complete set of top-level keys a legitimately-built, signed bundle can
|
|
244
|
+
// carry — buildEvidenceBundle's six plus signEvidenceBundle's `signature`.
|
|
245
|
+
// EA-03 (Stage-0 audit, 2026): canonicalBytes SIGNS an allowlist of fields;
|
|
246
|
+
// verifyEvidenceBundle never checked for keys OUTSIDE that allowlist, so a
|
|
247
|
+
// bundle with a fabricated `verdict`/`proofLevel`/anything-else stapled on
|
|
248
|
+
// after signing verified as authentic — the signature simply never covered
|
|
249
|
+
// those bytes. Rejecting unknown keys here closes that; it must exactly match
|
|
250
|
+
// what buildEvidenceBundle+signEvidenceBundle actually produce, or a
|
|
251
|
+
// legitimate bundle would start failing verification.
|
|
252
|
+
const BUNDLE_TOP_LEVEL_KEYS = new Set([
|
|
253
|
+
'schema', 'finding', 'evidence', 'engine', 'proves', 'doesNotProve', 'signature',
|
|
254
|
+
]);
|
|
255
|
+
|
|
230
256
|
export function verifyEvidenceBundle(bundle, publicKeyPem) {
|
|
231
257
|
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
232
258
|
if (bundle.schema !== BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
259
|
+
const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
260
|
+
if (unknownKeys.length) {
|
|
261
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
262
|
+
}
|
|
233
263
|
const sig = bundle.signature;
|
|
234
264
|
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
235
265
|
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
@@ -189,9 +189,21 @@ export function annotateExploitProbability(findings, ctx = {}) {
|
|
|
189
189
|
p = _clamp01(p);
|
|
190
190
|
// Wilson CI: prefer historical CI when we have one, otherwise derive
|
|
191
191
|
// a wider CI from the prior (n=10 implied sample at base rate).
|
|
192
|
+
//
|
|
193
|
+
// The point estimate (exploitProbability) is ALWAYS the per-finding,
|
|
194
|
+
// factor-adjusted `p` computed above — never the population-level
|
|
195
|
+
// historical hit rate. This module's own header scopes historical data
|
|
196
|
+
// explicitly to the CI ("Wilson CI computed from operator's historical
|
|
197
|
+
// hit rate... when enough samples exist"); overwriting the point
|
|
198
|
+
// estimate too collapsed every finding in a CWE×language slice with
|
|
199
|
+
// enough history to the SAME number regardless of its own reachability/
|
|
200
|
+
// sanitizer/auth signals — a maximally-dangerous finding and a
|
|
201
|
+
// maximally-safe one scored identically, while exploitProbabilityWhy
|
|
202
|
+
// kept listing factors that, in that branch, had zero effect on the
|
|
203
|
+
// reported number.
|
|
192
204
|
const hist = _historicalCi(history, cwe, f.language || (f.file || '').split('.').pop());
|
|
193
205
|
if (hist) {
|
|
194
|
-
f.exploitProbability =
|
|
206
|
+
f.exploitProbability = p;
|
|
195
207
|
f.exploitProbabilityCI95 = hist.ci;
|
|
196
208
|
f.exploitProbabilitySlice = hist.slice;
|
|
197
209
|
f.exploitProbabilityN = hist.n;
|
|
@@ -22,7 +22,19 @@ import {
|
|
|
22
22
|
} from './verification-separation.js';
|
|
23
23
|
|
|
24
24
|
const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
|
|
25
|
-
|
|
25
|
+
// Stage 3 correctness audit (detection depth): this ladder was missing
|
|
26
|
+
// 'critical' — exploitability.js sets f.exploitabilityTier = 'critical' at
|
|
27
|
+
// score >= 0.80 (the tier falsification most needs to demote, since it's
|
|
28
|
+
// exactly the findings a false "survives" verdict would most overstate).
|
|
29
|
+
// _dropTier('critical') hit the `i <= 0` "unknown tier, leave unchanged"
|
|
30
|
+
// branch (indexOf returns -1 for an unrecognized value), so a falsified
|
|
31
|
+
// finding at the critical exploitability tier kept its full tier — the
|
|
32
|
+
// demotion silently no-op'd for the highest tier in the system. 'very-low'
|
|
33
|
+
// (confidence.js's own floor tier) is included too, for the same reason
|
|
34
|
+
// confidenceTier is demoted by this same function — it was already
|
|
35
|
+
// unchanged-at-floor by the same `i <= 0` fallback, so this is a
|
|
36
|
+
// completeness fix there, not a behavior change.
|
|
37
|
+
const TIERS = ['very-low', 'low', 'medium', 'high', 'critical']; // confidence / exploitability tier order
|
|
26
38
|
|
|
27
39
|
function _dropTier(tier) {
|
|
28
40
|
const i = TIERS.indexOf(tier);
|
|
@@ -66,7 +78,16 @@ export function classifyFinding(finding, fileContents) {
|
|
|
66
78
|
}
|
|
67
79
|
// A sanitizer that doesn't match the sink context does NOT block the flow —
|
|
68
80
|
// the finding survives (this is a real bug, not a mitigation).
|
|
69
|
-
|
|
81
|
+
//
|
|
82
|
+
// Stage 3 correctness audit (detection depth): this was `=== true`, but
|
|
83
|
+
// the field's real producer (engine.js's applySanitizerEffectiveness)
|
|
84
|
+
// sets `f.sanitizerMismatch = f.sanitizerType` — a STRING sanitizer-type
|
|
85
|
+
// label ("Type Guard", "JWT Algo Pinning", ...), never the literal
|
|
86
|
+
// boolean `true`. Every OTHER consumer of this field (confidence.js,
|
|
87
|
+
// exploitability.js, engine.js's own scoring) checks it via plain
|
|
88
|
+
// truthiness; this strict-equality check could never match a real
|
|
89
|
+
// finding, making the whole branch dead code.
|
|
90
|
+
if (finding.sanitizerMismatch) {
|
|
70
91
|
return { verdict: 'survived', reasons: ['wrong-context sanitizer does not neutralize this sink'] };
|
|
71
92
|
}
|
|
72
93
|
const window = _pathWindow(finding, fileContents);
|
|
@@ -62,7 +62,7 @@ export function recordFixAttempt(scanRoot, record) {
|
|
|
62
62
|
try {
|
|
63
63
|
const dir = stateDir(scanRoot);
|
|
64
64
|
if (!isSafeStateDir(dir)) return false;
|
|
65
|
-
if (!stateWritesEnabled()) return;
|
|
65
|
+
if (!stateWritesEnabled()) return false;
|
|
66
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
67
67
|
// One writeSync of one newline-terminated line: a concurrent reader sees
|
|
68
68
|
// whole records or nothing, and a torn tail is dropped on read.
|
|
@@ -38,7 +38,16 @@ function _detectRunner(scanRoot) {
|
|
|
38
38
|
} catch { return null; }
|
|
39
39
|
})();
|
|
40
40
|
if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
|
|
41
|
-
|
|
41
|
+
// --passWithNoTests is Jest-specific CLI syntax — appending it
|
|
42
|
+
// unconditionally broke every non-Jest npm test script (mocha, vitest,
|
|
43
|
+
// ava, tap, or a plain node script) with an "unrecognized option" exit,
|
|
44
|
+
// failing verification for a reason that has nothing to do with
|
|
45
|
+
// whether the patch actually broke anything. Only add it when Jest is
|
|
46
|
+
// actually the configured runner.
|
|
47
|
+
const usesJest = /\bjest\b/.test(String(pkg.scripts.test))
|
|
48
|
+
|| Boolean(pkg.devDependencies?.jest) || Boolean(pkg.dependencies?.jest);
|
|
49
|
+
const args = usesJest ? ['test', '--silent', '--', '--passWithNoTests'] : ['test', '--silent'];
|
|
50
|
+
return { runner: 'npm', cmd: 'npm', args };
|
|
42
51
|
}
|
|
43
52
|
if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
|
|
44
53
|
return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
// IaC → application code reachability bridge (Sentinel-parity FR-DET-4).
|
|
2
2
|
//
|
|
3
|
-
// Detects publicly-exposed cloud resources in IaC
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// "exposed-via-iac" tag.
|
|
3
|
+
// Detects publicly-exposed cloud resources in IaC and correlates them with
|
|
4
|
+
// application-code references to the same resource (by name, ARN, or
|
|
5
|
+
// hostname). Application-code findings on resources that IaC has exposed get
|
|
6
|
+
// a severity bump and an explicit "exposed-via-iac" tag.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
8
|
+
// **Terraform only.** Only `parseTerraform` exists in this file — this header
|
|
9
|
+
// previously also listed CloudFormation and five Kubernetes patterns as
|
|
10
|
+
// detected; neither has any parser function anywhere here (found via
|
|
11
|
+
// Stage-0 doc audit, 2026; confirmed by grep for `function parse*` in this
|
|
12
|
+
// file, which returns exactly one hit). The pattern list below is therefore
|
|
13
|
+
// the Terraform-only reality, not the originally-documented superset:
|
|
10
14
|
//
|
|
11
15
|
// S3 bucket with public-read ACL / public-access-block disabled
|
|
12
16
|
// RDS / DocumentDB / Redshift with publicly_accessible = true
|
|
13
17
|
// Security group with 0.0.0.0/0 ingress on a sensitive port
|
|
14
18
|
// ALB / NLB / API Gateway with internet-facing scheme
|
|
15
|
-
// K8s Service of type LoadBalancer with no NetworkPolicy
|
|
16
|
-
// K8s Ingress with no auth annotation
|
|
17
19
|
// Lambda function URL with auth_type = NONE
|
|
18
20
|
// ECS task with assignPublicIp = ENABLED
|
|
19
21
|
//
|
|
22
|
+
// NOT IMPLEMENTED despite being previously documented here: CloudFormation
|
|
23
|
+
// support, K8s Service/LoadBalancer + NetworkPolicy correlation, K8s Ingress
|
|
24
|
+
// auth-annotation checking.
|
|
25
|
+
//
|
|
20
26
|
// Output: { exposedResources: [{name, kind, file, line, severity}], findings: [...new findings] }
|
|
21
27
|
|
|
22
28
|
const SENSITIVE_PORTS = new Set([22, 23, 25, 110, 143, 3306, 3389, 5432, 6379, 27017, 9200, 9300, 1521, 5984, 11211]);
|
package/src/posture/integrity.js
CHANGED
|
@@ -52,19 +52,37 @@ function _readOrGenerateKey() {
|
|
|
52
52
|
if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
|
|
53
53
|
}
|
|
54
54
|
} catch { /* fall through to generate */ }
|
|
55
|
-
// Generate, mode 0600.
|
|
55
|
+
// Generate, mode 0600. `wx` — exclusive create, same TOCTOU fix
|
|
56
|
+
// evidence-bundle.js's ensureKeyPair() already applies to its own key
|
|
57
|
+
// material: on first use, two concurrent processes can both pass the
|
|
58
|
+
// existsSync check above as false and both reach here. Without exclusive
|
|
59
|
+
// create, the last writer's key silently wins on disk while every OTHER
|
|
60
|
+
// process keeps signing with the key it generated and lost — a key that
|
|
61
|
+
// now exists nowhere, so every signature made under it fails to verify
|
|
62
|
+
// forever after, indistinguishable from real tampering.
|
|
56
63
|
const buf = crypto.randomBytes(32);
|
|
57
64
|
try {
|
|
58
65
|
fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
|
|
59
|
-
fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
|
|
66
|
+
fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600, flag: 'wx' });
|
|
60
67
|
_keySource = 'per-install-new';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
return buf;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.code === 'EEXIST') {
|
|
71
|
+
// Another process won the race and persisted its key first — use
|
|
72
|
+
// THAT key instead of the one we generated, or we'd return a key
|
|
73
|
+
// that matches nothing on disk.
|
|
74
|
+
try {
|
|
75
|
+
const hex = fs.readFileSync(fp, 'utf8').trim();
|
|
76
|
+
if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
|
|
77
|
+
} catch { /* fall through to ephemeral */ }
|
|
78
|
+
}
|
|
79
|
+
// Could not persist (or the winner's key was unreadable/malformed) —
|
|
80
|
+
// this key lives for this process only, so nothing signed with it will
|
|
81
|
+
// verify on any later run. Callers must be able to see that, or a
|
|
82
|
+
// permanently-unverifiable signature looks like a valid one.
|
|
65
83
|
_keySource = 'ephemeral';
|
|
84
|
+
return buf;
|
|
66
85
|
}
|
|
67
|
-
return buf;
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
// REMOVED (2026-08-08): the legacy hostname-derived key.
|
|
@@ -74,4 +74,69 @@ export function summarizeDelta(changed) {
|
|
|
74
74
|
return lines.join('\n');
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Stage 6 correctness audit: diffValidatorRuns/persistRescanReport/
|
|
78
|
+
// summarizeDelta above were fully built, but nothing in the codebase ever
|
|
79
|
+
// produced a `{model, results: {findingId: {verdict, reason}}}` run file
|
|
80
|
+
// for them to consume — commands/labs.md's `--model-rescan` mode was
|
|
81
|
+
// disclosed as genuinely unwired rather than fabricated. This is the
|
|
82
|
+
// missing producer: runs the SAME findings through the LLM validator twice
|
|
83
|
+
// — once under whatever model the environment currently resolves to
|
|
84
|
+
// ("from"), once under `toModel` ("to", via the existing per-role env
|
|
85
|
+
// override `AGENTIC_SECURITY_LLM_MODEL_VALIDATE` — no new plumbing needed,
|
|
86
|
+
// llm-validator/providers.js already supports it) — and turns the two runs
|
|
87
|
+
// into a real delta report. Reuses validateMany's own candidate filter
|
|
88
|
+
// (critical/high severity, low confidence, or AST parser) rather than
|
|
89
|
+
// re-validating every finding, matching normal validation scope. When no
|
|
90
|
+
// LLM endpoint is configured, validateMany degrades every finding to
|
|
91
|
+
// 'unvalidated' with no network call — this function inherits that
|
|
92
|
+
// no-network-by-default behavior rather than working around it.
|
|
93
|
+
export async function runModelRescan(scanRoot, { toModel } = {}) {
|
|
94
|
+
if (!toModel) return { ok: false, reason: 'no --model given to rescan with' };
|
|
95
|
+
const scan = _readJson(scanRoot, 'last-scan.json');
|
|
96
|
+
if (!scan) return { ok: false, reason: 'no .agentic-security/last-scan.json — run a scan first' };
|
|
97
|
+
const findings = Array.isArray(scan.findings) ? scan.findings : [];
|
|
98
|
+
if (!findings.length) return { ok: false, reason: 'last scan has no findings to re-validate' };
|
|
99
|
+
|
|
100
|
+
const fileContents = {};
|
|
101
|
+
for (const f of findings) {
|
|
102
|
+
if (!f.file || fileContents[f.file] !== undefined) continue;
|
|
103
|
+
try { fileContents[f.file] = fs.readFileSync(path.join(scanRoot, f.file), 'utf8'); }
|
|
104
|
+
catch { /* file may have moved/been deleted since the scan; validateMany skips it */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { validateMany } = await import('../llm-validator/index.js');
|
|
108
|
+
const { resolveProvider } = await import('../llm-validator/providers.js');
|
|
109
|
+
|
|
110
|
+
const runFor = async (envKey, modelOverride) => {
|
|
111
|
+
const prev = envKey ? process.env[envKey] : undefined;
|
|
112
|
+
if (envKey) process.env[envKey] = modelOverride;
|
|
113
|
+
let resolvedModel = 'unvalidated';
|
|
114
|
+
try {
|
|
115
|
+
const r = resolveProvider({ role: 'validate' });
|
|
116
|
+
if (r.ok) resolvedModel = r.config.model;
|
|
117
|
+
const clones = findings.map(f => ({ ...f }));
|
|
118
|
+
await validateMany(clones, { fileContents, scanRoot });
|
|
119
|
+
const results = {};
|
|
120
|
+
for (const f of clones) {
|
|
121
|
+
const id = f.stableId || f.id;
|
|
122
|
+
if (!id) continue;
|
|
123
|
+
results[id] = { verdict: f.validator_verdict || 'unvalidated', reason: f.validator_reasoning || null };
|
|
124
|
+
}
|
|
125
|
+
return { model: resolvedModel, results };
|
|
126
|
+
} finally {
|
|
127
|
+
if (envKey) {
|
|
128
|
+
if (prev === undefined) delete process.env[envKey];
|
|
129
|
+
else process.env[envKey] = prev;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const runA = await runFor(null, null);
|
|
135
|
+
const runB = await runFor('AGENTIC_SECURITY_LLM_MODEL_VALIDATE', toModel);
|
|
136
|
+
|
|
137
|
+
const changed = diffValidatorRuns(runA, runB);
|
|
138
|
+
const reportPath = persistRescanReport(scanRoot, runA.model, runB.model, changed);
|
|
139
|
+
return { ok: true, from: runA.model, to: runB.model, changed, reportPath, summary: summarizeDelta(changed) };
|
|
140
|
+
}
|
|
141
|
+
|
|
77
142
|
export const _internals = {};
|
package/src/posture/mttr.js
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
import * as crypto from 'node:crypto';
|
|
11
11
|
|
|
12
12
|
// Stable fingerprint for cross-scan finding identity. Mirrors the dedupe key.
|
|
13
|
+
// Exported so a caller can compute the "removed since baseline" (i.e. fixed)
|
|
14
|
+
// set that computeMTTR needs, using the exact same identity function
|
|
15
|
+
// buildBaselineMap uses internally — a caller-side reimplementation would
|
|
16
|
+
// risk drifting from this one and silently under/over-counting fixes.
|
|
17
|
+
export function fingerprintFinding(f) { return _fingerprint(f); }
|
|
13
18
|
function _fingerprint(f) {
|
|
14
19
|
const file = (f.file || '').split(' -> ').pop();
|
|
15
20
|
const line = f.line || f.source?.line || f.sink?.line || 0;
|
|
@@ -119,14 +119,33 @@ function _binding(finding, call) {
|
|
|
119
119
|
// The request property the handler reads. Anchored to the request identifier
|
|
120
120
|
// the export actually binds, so a file that reads `req.query` while exporting
|
|
121
121
|
// `(request, response)` does not produce a PoC built on the wrong name.
|
|
122
|
-
|
|
122
|
+
//
|
|
123
|
+
// A handler that reads more than one request property (extremely common —
|
|
124
|
+
// e.g. a harmless query param for pagination alongside the actual body
|
|
125
|
+
// param used in the sink) used to always get the first property found in a
|
|
126
|
+
// fixed query>body>params priority, with no relationship to which one
|
|
127
|
+
// actually reaches the sink — silently building a PoC against an inert
|
|
128
|
+
// parameter while the real injection point went untouched. When sinkLine
|
|
129
|
+
// is known, prefer whichever match sits closest to it (line proximity is a
|
|
130
|
+
// cheap, effective proxy for "this is the value that flows into the sink a
|
|
131
|
+
// few lines below/above it"); otherwise fall back to the old first-found
|
|
132
|
+
// behavior for callers that can't supply a line.
|
|
133
|
+
function _requestSource(content, reqIdent, sinkLine) {
|
|
123
134
|
const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
135
|
+
const candidates = [];
|
|
124
136
|
for (const prop of ['query', 'body', 'params']) {
|
|
125
|
-
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`, 'g');
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = re.exec(content))) {
|
|
140
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
141
|
+
candidates.push({ prop, key: m[1], line });
|
|
142
|
+
}
|
|
128
143
|
}
|
|
129
|
-
return null;
|
|
144
|
+
if (!candidates.length) return null;
|
|
145
|
+
if (typeof sinkLine === 'number') {
|
|
146
|
+
candidates.sort((a, b) => Math.abs(a.line - sinkLine) - Math.abs(b.line - sinkLine));
|
|
147
|
+
}
|
|
148
|
+
return { prop: candidates[0].prop, key: candidates[0].key };
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
// The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
|
|
@@ -184,7 +203,7 @@ export function synthesizeInProcessPoc(finding, fileContent) {
|
|
|
184
203
|
return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
|
|
185
204
|
}
|
|
186
205
|
|
|
187
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
206
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
188
207
|
if (!src) {
|
|
189
208
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
190
209
|
}
|
|
@@ -392,7 +411,7 @@ function _sqlInjectionPoc(finding, fileContent) {
|
|
|
392
411
|
if (!found) return NO_HANDLER;
|
|
393
412
|
const { call, reqIdent } = found;
|
|
394
413
|
|
|
395
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
414
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
396
415
|
if (!src) {
|
|
397
416
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
398
417
|
}
|
|
@@ -490,7 +509,7 @@ function _pathTraversalPoc(finding, fileContent) {
|
|
|
490
509
|
if (!READ_SINK.test(fileContent)) {
|
|
491
510
|
return { ok: false, reason: 'no readFile/sendFile sink in the file, so there is no served content to observe coming back' };
|
|
492
511
|
}
|
|
493
|
-
const src = _requestSource(fileContent, reqIdent);
|
|
512
|
+
const src = _requestSource(fileContent, reqIdent, finding.line);
|
|
494
513
|
if (!src) {
|
|
495
514
|
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the traversal point is unknown` };
|
|
496
515
|
}
|
|
@@ -103,18 +103,30 @@ test('${finding.vuln.replace(/'/g, "\\'")}', async () => {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function _renderPytest(finding, poc) {
|
|
106
|
+
// Same finding-specific extraction _renderJest uses, not the hardcoded
|
|
107
|
+
// SQL-injection payload every call used to emit regardless of the actual
|
|
108
|
+
// vulnerability class. poc.code is assumed to follow this project's own
|
|
109
|
+
// URL_/METHOD/PAYLOAD synthesis convention (poc-generator.js), same as
|
|
110
|
+
// the JS PoCs this extraction already works against.
|
|
111
|
+
const url = _extractUrl(poc.code) || 'http://localhost:3000';
|
|
112
|
+
const method = (_extractMethod(poc.code) || 'POST').toLowerCase();
|
|
113
|
+
const payload = _extractPayloadValue(poc.code) || 'PoC';
|
|
106
114
|
return `# Regression test for ${finding.vuln} (${finding.cwe || 'unknown CWE'}).
|
|
107
|
-
# Auto-generated from P1.1 PoC.
|
|
115
|
+
# Auto-generated from P1.1 PoC; fails on vulnerable code, passes after the fix.
|
|
108
116
|
|
|
109
117
|
import requests
|
|
110
118
|
|
|
111
119
|
|
|
112
120
|
def test_${(finding.stableId || finding.id || 'finding').replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 40)}():
|
|
113
|
-
url =
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
url = ${JSON.stringify(url)}
|
|
122
|
+
payload = ${JSON.stringify(payload)}
|
|
123
|
+
r = requests.${method}(url, json={'input': payload}) if ${JSON.stringify(method)} != 'get' \\
|
|
124
|
+
else requests.get(url, params={'input': payload})
|
|
125
|
+
# The assertion below is the "exploit demonstrated" signal — when this
|
|
126
|
+
# test PASSES, the vuln is still present. After the fix, this assertion
|
|
127
|
+
# SHOULD fail. Inverse the assertion in a green build.
|
|
116
128
|
text = r.text
|
|
117
|
-
demonstrated = 'syntax error' in text.lower() or '<script>' in text
|
|
129
|
+
demonstrated = 'PoC' in text or 'syntax error' in text.lower() or '<script>' in text
|
|
118
130
|
assert not demonstrated, f'Vulnerability still present: {text[:200]}'
|
|
119
131
|
`;
|
|
120
132
|
}
|
|
@@ -127,10 +139,13 @@ function _extractMethod(code) {
|
|
|
127
139
|
const m = String(code || '').match(/METHOD = (['"])([A-Z]+)\1/);
|
|
128
140
|
return m ? m[2] : null;
|
|
129
141
|
}
|
|
130
|
-
function
|
|
142
|
+
function _extractPayloadValue(code) {
|
|
131
143
|
const m = String(code || '').match(/PAYLOAD = `([^`]+)`/);
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
return m ? m[1] : null;
|
|
145
|
+
}
|
|
146
|
+
function _extractPayloadLine(code) {
|
|
147
|
+
const v = _extractPayloadValue(code);
|
|
148
|
+
return `const PAYLOAD = ${JSON.stringify(v || 'PoC')};`;
|
|
134
149
|
}
|
|
135
150
|
|
|
136
151
|
/**
|
|
@@ -96,7 +96,11 @@ export function annotateScaReverseBlast(findings, fileContents) {
|
|
|
96
96
|
if (!Object.keys(map).length) return findings;
|
|
97
97
|
for (const f of findings) {
|
|
98
98
|
if (!f || typeof f !== 'object') continue;
|
|
99
|
-
|
|
99
|
+
// SCA findings (engine.js's queryOSV) carry the package name as `.name`
|
|
100
|
+
// — `.package`/`.dependency`/`.pkg` are never set anywhere in this
|
|
101
|
+
// codebase; kept as a fallback in case a caller supplies a differently-
|
|
102
|
+
// shaped finding.
|
|
103
|
+
const pkg = f.name || f.package || f.dependency || f.pkg;
|
|
100
104
|
if (!pkg || !map[pkg]) continue;
|
|
101
105
|
f.reverseExposure = {
|
|
102
106
|
importerCount: map[pkg].directImporters.length,
|
|
@@ -110,8 +110,25 @@ function _impactFor(finding, cfg) {
|
|
|
110
110
|
return table.default;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// SCA entries carry reachabilityTier/routeReachable (engine.js's SCA
|
|
114
|
+
// reachability pass); SAST findings never do — they carry relevanceTier/
|
|
115
|
+
// entrypointReachable instead (posture/relevance.js). Without this
|
|
116
|
+
// fallback, _reachDiscount always read 'unknown' (0.3) for every SAST
|
|
117
|
+
// finding, regardless of whether it was actually route-reachable.
|
|
118
|
+
function _relevanceTierToReachTier(relevanceTier) {
|
|
119
|
+
switch (relevanceTier) {
|
|
120
|
+
case 'direct': return 'route-reachable';
|
|
121
|
+
case 'indirect': return 'function-reachable';
|
|
122
|
+
case 'unreachable': return 'unreachable';
|
|
123
|
+
default: return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function _reachDiscount(finding) {
|
|
114
|
-
const tier = finding.reachabilityTier
|
|
128
|
+
const tier = finding.reachabilityTier
|
|
129
|
+
|| (finding.routeReachable && 'route-reachable')
|
|
130
|
+
|| _relevanceTierToReachTier(finding.relevanceTier)
|
|
131
|
+
|| 'unknown';
|
|
115
132
|
return REACH_DISCOUNT[tier] || 0.3;
|
|
116
133
|
}
|
|
117
134
|
|
|
@@ -31,17 +31,26 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
31
31
|
if (!added.trim()) return [];
|
|
32
32
|
let findings = [];
|
|
33
33
|
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
34
|
-
return findings.map((f) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
return findings.map((f) => {
|
|
35
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
36
|
+
return {
|
|
37
|
+
...f,
|
|
38
|
+
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
39
|
+
file: `git-history@${commit}`,
|
|
40
|
+
line: 0,
|
|
41
|
+
commit,
|
|
42
|
+
_historical: true,
|
|
43
|
+
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
44
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
45
|
+
remediation,
|
|
46
|
+
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
47
|
+
// — the underlying detector already set `.fix` to a generic "remove
|
|
48
|
+
// the line" string, which would otherwise silently shadow this
|
|
49
|
+
// history-specific instruction ("removing it from HEAD alone is
|
|
50
|
+
// insufficient") in every report format.
|
|
51
|
+
fix: remediation,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
/**
|
|
@@ -35,7 +35,13 @@ function _snapshotFromScan(scan, label) {
|
|
|
35
35
|
medium: findings.filter(f => f.severity === 'medium').length,
|
|
36
36
|
low: findings.filter(f => f.severity === 'low').length,
|
|
37
37
|
kev: findings.filter(f => f.kev).length,
|
|
38
|
-
|
|
38
|
+
// stable-id.js exists specifically because the default `id` embeds file
|
|
39
|
+
// path + line number, so any refactor that shifts a line rotates the id
|
|
40
|
+
// — using it here would report the same unfixed vulnerability as one
|
|
41
|
+
// "fixed" finding and one "introduced" finding on every such shift.
|
|
42
|
+
// stableId omits the exact line by design; fall back to `id` only for
|
|
43
|
+
// finding shapes that never got a stableId annotated.
|
|
44
|
+
ids: new Set(findings.map(f => f.stableId || f.id).filter(Boolean)),
|
|
39
45
|
};
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -185,6 +185,18 @@ function _buildPlaybook(stack) {
|
|
|
185
185
|
]});
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Express
|
|
189
|
+
if (stack.has('express')) {
|
|
190
|
+
sections.push({ title: 'Express', items: [
|
|
191
|
+
'Use helmet() to set security headers (X-Frame-Options, X-Content-Type-Options, HSTS) — Express sets none of these by default',
|
|
192
|
+
'Never use body-parser / express.json() without a size limit — set `limit` explicitly to prevent request-body DoS',
|
|
193
|
+
'Apply express-rate-limit to authentication and any expensive routes',
|
|
194
|
+
'Validate and sanitize all req.params / req.query / req.body — Express does not validate input for you',
|
|
195
|
+
'Set `app.disable(\'x-powered-by\')` so error responses and headers do not advertise the framework/version to attackers',
|
|
196
|
+
'Use a CSRF middleware (e.g. csrf-csrf) on any route that relies on cookie-based sessions',
|
|
197
|
+
]});
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
// Django
|
|
189
201
|
if (stack.has('django')) {
|
|
190
202
|
sections.push({ title: 'Django', items: [
|
|
@@ -202,7 +214,16 @@ function _buildPlaybook(stack) {
|
|
|
202
214
|
function _findingFromItem(scanRoot, stackName, item, idx) {
|
|
203
215
|
return {
|
|
204
216
|
id: `stack-playbook:${stackName.replace(/\s+/g, '_').toUpperCase()}:${idx}`,
|
|
205
|
-
|
|
217
|
+
// The findings schema requires `vuln` (root CLAUDE.md); this used to set
|
|
218
|
+
// `title` instead, which isn't a schema field at all. engine.js's generic
|
|
219
|
+
// no-vuln-name filter (`_shouldKeep`) treats any non-SCA finding with no
|
|
220
|
+
// `vuln` string as unenriched noise and drops it — silently, for every
|
|
221
|
+
// stack, confirmed live via a real scan whose logicVulns went from 6
|
|
222
|
+
// playbook findings right after they were pushed to 0 by the time the
|
|
223
|
+
// scan returned. `vuln` is now the actionable string this finding is
|
|
224
|
+
// actually about; `description`/`remediation` (already correct) keep
|
|
225
|
+
// the fuller text.
|
|
226
|
+
vuln: `[${stackName} Security Checklist] ${item.slice(0, 80)}`,
|
|
206
227
|
severity: 'info',
|
|
207
228
|
file: 'package.json',
|
|
208
229
|
line: 1,
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// SOC2 / HIPAA / GDPR) adds compliance-tag fields to findings in
|
|
14
14
|
// matching families (PII → HIPAA/GDPR; auth → SOC2 CC6.1; etc.).
|
|
15
15
|
// - **Stated attacker** — "## Attacker model" / "## Threat actor"
|
|
16
|
-
// section sets f.
|
|
17
|
-
// for use in downstream prioritization.
|
|
16
|
+
// section sets f.threatModel.attacker = 'script-kiddie' | 'apt' |
|
|
17
|
+
// 'insider' for use in downstream prioritization.
|
|
18
18
|
//
|
|
19
19
|
// Opt-out: AGENTIC_SECURITY_NO_THREAT_MODEL_GROUNDING=1
|
|
20
20
|
|
|
@@ -92,15 +92,22 @@ export function recordTriage(scanRoot, { family, verdict, stableId }) {
|
|
|
92
92
|
const data = _read(scanRoot);
|
|
93
93
|
data.productionTriage = data.productionTriage || {};
|
|
94
94
|
const row = data.productionTriage[family] = data.productionTriage[family] || { tp: 0, fp: 0, wontfix: 0, lastAt: null };
|
|
95
|
+
void stableId;
|
|
96
|
+
// Already frozen from a previous call — `_capped: true` was persisted at
|
|
97
|
+
// the moment the cap was crossed (below), so this is a deliberate,
|
|
98
|
+
// visible freeze: nothing new is written, but nothing was silently lost
|
|
99
|
+
// either. Previously the crossing call itself never called _write, so
|
|
100
|
+
// `_capped` never reached disk and EVERY call after the cap — not just
|
|
101
|
+
// more of the same verdict, any verdict — silently vanished with the
|
|
102
|
+
// on-disk row frozen one write short of the real crossing point.
|
|
103
|
+
if (row._capped) return row;
|
|
95
104
|
row[verdict] = (row[verdict] || 0) + 1;
|
|
96
105
|
row.lastAt = new Date().toISOString();
|
|
97
106
|
// Cap per-family rows so a runaway triage script can't bloat the file.
|
|
107
|
+
// The crossing call still writes — that's what makes the freeze visible.
|
|
98
108
|
if ((row.tp || 0) + (row.fp || 0) + (row.wontfix || 0) > 10_000) {
|
|
99
|
-
// Stop accumulating; the trend is well-established by now.
|
|
100
109
|
row._capped = true;
|
|
101
|
-
return row;
|
|
102
110
|
}
|
|
103
|
-
void stableId;
|
|
104
111
|
_write(scanRoot, data);
|
|
105
112
|
return row;
|
|
106
113
|
}
|