@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +880 -0
- package/bin/agentic-security.js +189 -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 +192 -15
- 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 +15 -15
- 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 +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- 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 +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- 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/clustering.js +12 -1
- 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/sbom.js +2 -2
- 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
|
@@ -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
|
|
package/src/posture/sbom.js
CHANGED
|
@@ -61,7 +61,7 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
61
61
|
version: 1,
|
|
62
62
|
metadata: {
|
|
63
63
|
timestamp: meta.startedAt || new Date().toISOString(),
|
|
64
|
-
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version:
|
|
64
|
+
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
|
|
65
65
|
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
66
66
|
},
|
|
67
67
|
components: cdxComponents,
|
|
@@ -117,7 +117,7 @@ export function toSPDX(scan, meta = {}) {
|
|
|
117
117
|
documentNamespace: docNamespace,
|
|
118
118
|
creationInfo: {
|
|
119
119
|
created: ts,
|
|
120
|
-
creators: [
|
|
120
|
+
creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
|
|
121
121
|
},
|
|
122
122
|
packages,
|
|
123
123
|
relationships: packages.map(p => ({
|
|
@@ -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
|
}
|
package/src/posture/verifier.js
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// against a caller-provided target URL (AGENTIC_SECURITY_VERIFY_TARGET).
|
|
18
18
|
// Without a target, live mode falls back to validate-only with a
|
|
19
19
|
// `cannot-verify` verdict + reason 'no-target'.
|
|
20
|
-
// * Sandbox:
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// * Sandbox: live execution runs through src/sandbox/index.js's confined
|
|
21
|
+
// execution facility (the same one execution-proof.js uses) — never a
|
|
22
|
+
// bare, unconfined subprocess. When no confinement primitive is
|
|
23
|
+
// available on the host, live verification refuses rather than falling
|
|
24
|
+
// back to running the PoC unconfined.
|
|
23
25
|
//
|
|
24
26
|
// Fail-closed semantics (FR-VER-7): any error — Docker missing, target down,
|
|
25
27
|
// PoC throws — produces `cannot-verify`, never `rejected`. An attacker who
|
|
@@ -28,7 +30,7 @@
|
|
|
28
30
|
import * as fs from 'node:fs';
|
|
29
31
|
import * as path from 'node:path';
|
|
30
32
|
import * as os from 'node:os';
|
|
31
|
-
import {
|
|
33
|
+
import { runConfined, sandboxAvailable } from '../sandbox/index.js';
|
|
32
34
|
import { isExplicitlyNoPoc } from './poc-cwe-map.js';
|
|
33
35
|
|
|
34
36
|
// ─── PoC static validation ──────────────────────────────────────────────────
|
|
@@ -122,18 +124,36 @@ export function proveSanitizerAbsence(finding, fileContents) {
|
|
|
122
124
|
function runSandboxed(poc, opts = {}) {
|
|
123
125
|
const target = opts.target;
|
|
124
126
|
if (!target) return { ok: false, reason: 'no-target' };
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
127
|
+
if (!sandboxAvailable() && !opts.force) {
|
|
128
|
+
return { ok: false, reason: 'no confinement primitive available on this host; refusing to execute the PoC unconfined', runner: 'disabled' };
|
|
129
|
+
}
|
|
130
|
+
// Materialise the PoC into a fresh sandbox root.
|
|
131
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'as-poc-')));
|
|
132
|
+
const file = poc.lang === 'python' ? 'poc.py' : 'poc.mjs';
|
|
128
133
|
try {
|
|
129
|
-
fs.writeFileSync(file, _patchTarget(poc.code, target));
|
|
134
|
+
fs.writeFileSync(path.join(dir, file), _patchTarget(poc.code, target));
|
|
130
135
|
} catch (e) {
|
|
136
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
131
137
|
return { ok: false, reason: `write-failed:${e.message}` };
|
|
132
138
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
try {
|
|
140
|
+
const argv = poc.lang === 'python' ? ['python3', file] : [process.execPath, file];
|
|
141
|
+
// allowNetwork: the whole point of live verification is reaching the
|
|
142
|
+
// caller-provided target — writes and everything else stay confined.
|
|
143
|
+
const r = runConfined(argv, { root: dir, timeoutMs: opts.timeoutMs || 15000, allowNetwork: true, force: opts.force });
|
|
144
|
+
if (r.status === 'disabled') {
|
|
145
|
+
return { ok: false, reason: 'confined execution is disabled; the PoC was refused and never executed', runner: r.backend };
|
|
146
|
+
}
|
|
147
|
+
if (r.status === 'error') {
|
|
148
|
+
return { ok: false, reason: `sandbox-error:${(r.stderr || '').trim() || 'unknown'}`, runner: r.backend };
|
|
149
|
+
}
|
|
150
|
+
if (r.timedOut) {
|
|
151
|
+
return { ok: false, reason: 'poc-timeout', runner: r.backend };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, exitCode: r.exitCode, stderr: r.stderr || '', stdout: r.stdout || '', runner: r.backend, denied: r.denied };
|
|
154
|
+
} finally {
|
|
155
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
156
|
+
}
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
function _patchTarget(code, target) {
|
|
@@ -141,51 +161,6 @@ function _patchTarget(code, target) {
|
|
|
141
161
|
return code.replace(/http:\/\/localhost:3000/g, target);
|
|
142
162
|
}
|
|
143
163
|
|
|
144
|
-
function _haveDocker() {
|
|
145
|
-
try {
|
|
146
|
-
const r = spawnSync('docker', ['version'], { stdio: 'ignore', timeout: 3000 });
|
|
147
|
-
return r.status === 0;
|
|
148
|
-
} catch { return false; }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function _runDocker(file, dir, lang, opts) {
|
|
152
|
-
const image = lang === 'python' ? 'python:3.12-slim' : 'node:22-slim';
|
|
153
|
-
const cmd = lang === 'python' ? ['python3', '/work/poc.py'] : ['node', '/work/poc.mjs'];
|
|
154
|
-
const args = [
|
|
155
|
-
'run', '--rm',
|
|
156
|
-
'--network=host', // PoC must reach the target; host is the smallest blast radius
|
|
157
|
-
'--cap-drop=ALL',
|
|
158
|
-
'--memory=256m',
|
|
159
|
-
'--cpu-quota=20000',
|
|
160
|
-
'--pids-limit=64',
|
|
161
|
-
'--read-only',
|
|
162
|
-
'--tmpfs=/tmp',
|
|
163
|
-
'--user', 'nobody',
|
|
164
|
-
'-v', `${dir}:/work:ro`,
|
|
165
|
-
image,
|
|
166
|
-
...cmd,
|
|
167
|
-
];
|
|
168
|
-
const r = spawnSync('docker', args, {
|
|
169
|
-
timeout: opts.timeoutMs || 15000,
|
|
170
|
-
encoding: 'utf8',
|
|
171
|
-
});
|
|
172
|
-
if (r.error) return { ok: false, reason: `docker-error:${r.error.code || r.error.message}`, runner: 'docker' };
|
|
173
|
-
return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'docker' };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function _runSubprocess(file, lang, opts) {
|
|
177
|
-
const bin = lang === 'python' ? 'python3' : 'node';
|
|
178
|
-
const r = spawnSync(bin, [file], {
|
|
179
|
-
timeout: opts.timeoutMs || 15000,
|
|
180
|
-
encoding: 'utf8',
|
|
181
|
-
// Best-effort containment without Docker. Operators are warned in stderr
|
|
182
|
-
// that the subprocess fallback offers materially weaker isolation.
|
|
183
|
-
env: { PATH: process.env.PATH || '', NODE_OPTIONS: '' },
|
|
184
|
-
});
|
|
185
|
-
if (r.error) return { ok: false, reason: `subprocess-error:${r.error.code || r.error.message}`, runner: 'subprocess' };
|
|
186
|
-
return { ok: true, exitCode: r.status, stderr: r.stderr || '', stdout: r.stdout || '', runner: 'subprocess' };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
164
|
// ─── Per-finding verdict assignment ─────────────────────────────────────────
|
|
190
165
|
|
|
191
166
|
export function verdictForFinding(finding, ctx = {}) {
|