@clear-capabilities/agentic-security-scanner 0.78.0 → 0.80.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/bin/.agentic-security/findings.json +16 -16
- package/bin/.agentic-security/last-scan.json +16 -16
- package/bin/.agentic-security/last-scan.json.sig +1 -1
- package/bin/.agentic-security/scan-history.json +51 -0
- package/bin/.agentic-security/streak.json +5 -5
- package/bin/agentic-security.js +22 -7
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +53 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +95 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +29799 -7803
- package/src/.agentic-security/last-scan.json +29799 -7803
- package/src/.agentic-security/last-scan.json.sig +1 -1
- package/src/.agentic-security/scan-history.json +5119 -2611
- package/src/.agentic-security/streak.json +6 -6
- package/src/dataflow/.agentic-security/findings.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
- package/src/dataflow/.agentic-security/scan-history.json +68 -520
- package/src/dataflow/.agentic-security/streak.json +6 -7
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/engine.js +52 -8
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +890 -132
- package/src/integrations/index.js +2 -1
- package/src/ir/.agentic-security/findings.json +240 -6
- package/src/ir/.agentic-security/last-scan.json +240 -6
- package/src/ir/.agentic-security/last-scan.json.sig +1 -1
- package/src/ir/.agentic-security/scan-history.json +16 -594
- package/src/ir/.agentic-security/streak.json +8 -9
- package/src/ir/callgraph.js +27 -7
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/llm-validator/index.js +7 -5
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/audit.js +5 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json.sig +1 -1
- package/src/posture/.agentic-security/scan-history.json +6689 -177
- package/src/posture/.agentic-security/streak.json +8 -7
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +3 -2
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/fix-history.js +8 -2
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/profile.js +4 -5
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +29 -6
- package/src/posture/validator-metrics.js +3 -6
- package/src/sast/.agentic-security/findings.json +996 -32
- package/src/sast/.agentic-security/last-scan.json +996 -32
- package/src/sast/.agentic-security/last-scan.json.sig +1 -1
- package/src/sast/.agentic-security/scan-history.json +565 -32
- package/src/sast/.agentic-security/streak.json +10 -8
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/db-taint.js +24 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/rust.js +26 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json.sig +1 -1
- package/src/sca/.agentic-security/scan-history.json +83 -6
- package/src/sca/.agentic-security/streak.json +9 -9
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +146 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/sigstore-verify.js +215 -0
- package/src/sca/vendor-detect.js +53 -0
- package/src/report/.agentic-security/findings.json +0 -80
- package/src/report/.agentic-security/last-scan.json +0 -80
- package/src/report/.agentic-security/last-scan.json.sig +0 -1
- package/src/report/.agentic-security/scan-history.json +0 -35
- package/src/report/.agentic-security/streak.json +0 -22
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"firstScanDate": "2026-05-
|
|
3
|
-
"lastScanDate": "2026-05-
|
|
4
|
-
"totalScans":
|
|
2
|
+
"firstScanDate": "2026-05-28T21:51:05.470Z",
|
|
3
|
+
"lastScanDate": "2026-05-29T06:49:36.319Z",
|
|
4
|
+
"totalScans": 8,
|
|
5
5
|
"daysCleanCritical": 2,
|
|
6
|
-
"lastCleanDate": "2026-05-
|
|
6
|
+
"lastCleanDate": "2026-05-29",
|
|
7
7
|
"lastCriticalDate": null,
|
|
8
8
|
"hasEverHadCritical": false,
|
|
9
9
|
"bestDaysCleanCritical": 2,
|
|
10
10
|
"totalFindingsAtFirstScan": 17,
|
|
11
|
-
"totalFindingsAtLastScan":
|
|
11
|
+
"totalFindingsAtLastScan": 30,
|
|
12
12
|
"totalFixesInferred": 0,
|
|
13
13
|
"lastGrade": "A",
|
|
14
14
|
"bestGrade": "A",
|
|
15
15
|
"launchCheckPassedAt": null,
|
|
16
16
|
"achievements": [
|
|
17
17
|
"first-scan",
|
|
18
|
-
"grade-a"
|
|
19
|
-
"scan-veteran-25"
|
|
18
|
+
"grade-a"
|
|
20
19
|
],
|
|
21
20
|
"previousGrade": "A"
|
|
22
21
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Cross-repo / cross-service taint — Recommendation #4 of the world-class
|
|
2
|
+
// roadmap.
|
|
3
|
+
//
|
|
4
|
+
// Discovers vulnerabilities that no single-repo scan can find: tainted
|
|
5
|
+
// data flowing from service A's HTTP request body, through A's response,
|
|
6
|
+
// into service B's consumer, then to a sink inside B. The "trust this
|
|
7
|
+
// because the upstream team owns it" assumption is what kills companies;
|
|
8
|
+
// the scanner catches it by reading a per-project service-graph file and
|
|
9
|
+
// propagating taint across service boundaries.
|
|
10
|
+
//
|
|
11
|
+
// Inputs:
|
|
12
|
+
// .agentic-security/services.yml — declares service-to-service edges:
|
|
13
|
+
//
|
|
14
|
+
// services:
|
|
15
|
+
// payments:
|
|
16
|
+
// repo: github.com/acme/payments
|
|
17
|
+
// exposes:
|
|
18
|
+
// - { route: "POST /charges", taints: ["request.amount", "request.cardToken"] }
|
|
19
|
+
// consumes:
|
|
20
|
+
// - { source: "events.charge_created", fields: ["amount", "cardToken"] }
|
|
21
|
+
// ledger:
|
|
22
|
+
// repo: github.com/acme/ledger
|
|
23
|
+
// exposes:
|
|
24
|
+
// - { route: "GET /balances/:userId", taints: ["pathParam.userId"] }
|
|
25
|
+
//
|
|
26
|
+
// edges:
|
|
27
|
+
// - { from: "payments", to: "ledger", via: "http", path: "/balances/{userId}" }
|
|
28
|
+
// - { from: "payments", to: "fraud", via: "kafka", topic: "events.charge_created" }
|
|
29
|
+
//
|
|
30
|
+
// The scanner uses this graph to:
|
|
31
|
+
// 1. Mark every "consumes" entry-point in each service as tainted-by-default
|
|
32
|
+
// 2. Walk the call graph from those entry points to any sink
|
|
33
|
+
// 3. When a finding's sink is reachable from a cross-service edge, emit a
|
|
34
|
+
// `crossService: { from, to, via, path }` annotation
|
|
35
|
+
// 4. Bump severity by one tier because cross-service taint is by definition
|
|
36
|
+
// reaching across a trust boundary
|
|
37
|
+
//
|
|
38
|
+
// In v1 we don't run BOTH services in one scan — that would require
|
|
39
|
+
// either a monorepo or a federated-scan API. We DO emit cross-service
|
|
40
|
+
// findings when the local service is on the receiving end of an edge,
|
|
41
|
+
// based on the declared upstream taint contract.
|
|
42
|
+
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
import * as yaml from 'js-yaml';
|
|
46
|
+
|
|
47
|
+
const SERVICES_FILE_NAMES = ['services.yml', 'services.yaml'];
|
|
48
|
+
|
|
49
|
+
export function loadServiceGraph(scanRoot) {
|
|
50
|
+
if (!scanRoot) return null;
|
|
51
|
+
for (const name of SERVICES_FILE_NAMES) {
|
|
52
|
+
const fp = path.join(scanRoot, '.agentic-security', name);
|
|
53
|
+
if (!fs.existsSync(fp)) continue;
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
56
|
+
const doc = yaml.load(raw);
|
|
57
|
+
return _normalizeGraph(doc);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { _error: `Failed to parse ${fp}: ${e.message}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _normalizeGraph(doc) {
|
|
66
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
67
|
+
const services = {};
|
|
68
|
+
for (const [name, def] of Object.entries(doc.services || {})) {
|
|
69
|
+
services[name] = {
|
|
70
|
+
name,
|
|
71
|
+
repo: def.repo || null,
|
|
72
|
+
exposes: Array.isArray(def.exposes) ? def.exposes : [],
|
|
73
|
+
consumes: Array.isArray(def.consumes) ? def.consumes : [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const edges = Array.isArray(doc.edges) ? doc.edges.map(e => ({
|
|
77
|
+
from: e.from, to: e.to,
|
|
78
|
+
via: e.via || 'http',
|
|
79
|
+
path: e.path || null,
|
|
80
|
+
topic: e.topic || null,
|
|
81
|
+
})) : [];
|
|
82
|
+
return { services, edges };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Identify the "current service" by name. Two heuristics:
|
|
87
|
+
* 1. If the scanRoot's package.json / pyproject.toml / etc. name matches
|
|
88
|
+
* a service in the graph, that's us.
|
|
89
|
+
* 2. Otherwise fall back to the basename of the scanRoot.
|
|
90
|
+
*/
|
|
91
|
+
export function identifyCurrentService(graph, scanRoot) {
|
|
92
|
+
if (!graph || !graph.services) return null;
|
|
93
|
+
// Try package.json / pyproject.toml name field.
|
|
94
|
+
let projectName = null;
|
|
95
|
+
try {
|
|
96
|
+
const pkg = path.join(scanRoot, 'package.json');
|
|
97
|
+
if (fs.existsSync(pkg)) {
|
|
98
|
+
const j = JSON.parse(fs.readFileSync(pkg, 'utf8'));
|
|
99
|
+
projectName = j.name;
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
if (projectName && graph.services[projectName]) return graph.services[projectName];
|
|
103
|
+
const base = path.basename(scanRoot);
|
|
104
|
+
if (graph.services[base]) return graph.services[base];
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute the list of incoming "edges" terminating at the current
|
|
110
|
+
* service. Each edge identifies which upstream service is the source of
|
|
111
|
+
* taint into this service.
|
|
112
|
+
*/
|
|
113
|
+
export function incomingEdges(graph, currentService) {
|
|
114
|
+
if (!graph || !currentService) return [];
|
|
115
|
+
return (graph.edges || []).filter(e => e.to === currentService.name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* For each consume entry on the current service, locate the upstream
|
|
120
|
+
* `exposes` entry that produces it (matched by path/topic) and return
|
|
121
|
+
* the upstream-declared tainted fields. This is the data we use to
|
|
122
|
+
* mark code entry points as tainted-by-default during scanning.
|
|
123
|
+
*/
|
|
124
|
+
export function upstreamTaintContract(graph, currentService) {
|
|
125
|
+
if (!graph || !currentService) return [];
|
|
126
|
+
const contracts = [];
|
|
127
|
+
for (const consume of currentService.consumes || []) {
|
|
128
|
+
for (const edge of (graph.edges || [])) {
|
|
129
|
+
if (edge.to !== currentService.name) continue;
|
|
130
|
+
const upstream = graph.services[edge.from];
|
|
131
|
+
if (!upstream) continue;
|
|
132
|
+
for (const expose of upstream.exposes || []) {
|
|
133
|
+
const matches = (edge.via === 'http' && edge.path && expose.route && expose.route.includes(edge.path.split('?')[0]))
|
|
134
|
+
|| (edge.via === 'kafka' && edge.topic && consume.source === edge.topic);
|
|
135
|
+
if (matches) {
|
|
136
|
+
contracts.push({
|
|
137
|
+
upstreamService: upstream.name,
|
|
138
|
+
via: edge.via,
|
|
139
|
+
taintedFields: [...(consume.fields || []), ...(expose.taints || [])],
|
|
140
|
+
consume,
|
|
141
|
+
expose,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return contracts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Annotate findings whose source matches a cross-service taint contract.
|
|
152
|
+
* Adds `crossService: { from, via, path, taintedFields }` and bumps
|
|
153
|
+
* severity by one tier (medium → high, high → critical).
|
|
154
|
+
*/
|
|
155
|
+
export function annotateCrossServiceFindings(findings, graph, currentService) {
|
|
156
|
+
if (!Array.isArray(findings) || !graph || !currentService) return { annotated: 0, bumped: 0 };
|
|
157
|
+
const contracts = upstreamTaintContract(graph, currentService);
|
|
158
|
+
if (!contracts.length) return { annotated: 0, bumped: 0 };
|
|
159
|
+
let annotated = 0, bumped = 0;
|
|
160
|
+
for (const f of findings) {
|
|
161
|
+
const sourceExpr = (f.source && (f.source.snippet || f.source.expr)) || f.snippet || '';
|
|
162
|
+
for (const contract of contracts) {
|
|
163
|
+
const matches = contract.taintedFields.some(field => {
|
|
164
|
+
const pat = new RegExp(`\\b${field.replace('.', '\\.')}\\b`);
|
|
165
|
+
return pat.test(sourceExpr);
|
|
166
|
+
});
|
|
167
|
+
if (!matches) continue;
|
|
168
|
+
f.crossService = {
|
|
169
|
+
from: contract.upstreamService,
|
|
170
|
+
to: currentService.name,
|
|
171
|
+
via: contract.via,
|
|
172
|
+
taintedField: contract.taintedFields.find(field => new RegExp(`\\b${field.replace('.', '\\.')}\\b`).test(sourceExpr)),
|
|
173
|
+
};
|
|
174
|
+
annotated++;
|
|
175
|
+
// Severity bump.
|
|
176
|
+
const ladder = ['info', 'low', 'medium', 'high', 'critical'];
|
|
177
|
+
const cur = ladder.indexOf(f.severity);
|
|
178
|
+
if (cur > 0 && cur < ladder.length - 1) {
|
|
179
|
+
f._severityBumpReason = `cross-service-from:${contract.upstreamService}`;
|
|
180
|
+
f.severity = ladder[cur + 1];
|
|
181
|
+
bumped++;
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { annotated, bumped };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run the cross-service annotation pass — convenience entry point called
|
|
191
|
+
* from the engine after the normal scan completes.
|
|
192
|
+
*/
|
|
193
|
+
export function runCrossServiceTaint(scanRoot, findings) {
|
|
194
|
+
const graph = loadServiceGraph(scanRoot);
|
|
195
|
+
if (!graph || graph._error) return { error: graph?._error, annotated: 0 };
|
|
196
|
+
const current = identifyCurrentService(graph, scanRoot);
|
|
197
|
+
if (!current) return { error: 'no-current-service-identified', annotated: 0 };
|
|
198
|
+
return annotateCrossServiceFindings(findings, graph, current);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const _internals = { _normalizeGraph, SERVICES_FILE_NAMES };
|
package/src/dataflow/engine.js
CHANGED
|
@@ -62,13 +62,13 @@ function _addPathAliasAware(state, path, callContext) {
|
|
|
62
62
|
return s;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
let _activeConstantVars = null;
|
|
66
|
+
|
|
65
67
|
function exprTaint(expr, state) {
|
|
66
|
-
// Returns true iff this expression evaluates to a tainted value under the
|
|
67
|
-
// given taint state. ALSO treats catalog-registered source patterns as
|
|
68
|
-
// tainted at-read — `req.body.host` used inline (no intermediate local)
|
|
69
|
-
// is tainted because the source resolves at the read site.
|
|
70
68
|
if (expr && expr.kind === 'member' && exprIsSource(expr)) return true;
|
|
71
69
|
if (!expr) return false;
|
|
70
|
+
// Constant propagation: variables assigned from literals are never tainted
|
|
71
|
+
if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
|
|
72
72
|
// P1.1 — field-sensitive access path: if the expression is a pure
|
|
73
73
|
// ident/member chain ("x.y.z"), ask the access-path lattice whether any
|
|
74
74
|
// shorter prefix in the state covers it. This is what makes
|
|
@@ -157,13 +157,35 @@ function exprIsSource(expr) {
|
|
|
157
157
|
const hit = matchSource(expr);
|
|
158
158
|
if (hit) return hit;
|
|
159
159
|
}
|
|
160
|
-
// Recurse — `req.body.name` should still find `req.body` as source.
|
|
161
160
|
if (expr.kind === 'member' && expr.object) {
|
|
162
161
|
return exprIsSource(expr.object);
|
|
163
162
|
}
|
|
164
163
|
return null;
|
|
165
164
|
}
|
|
166
165
|
|
|
166
|
+
const _SQL_KEYWORDS = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|UNION|WHERE|FROM|JOIN|INTO|VALUES|SET|EXEC|EXECUTE)\b/i;
|
|
167
|
+
const _HTML_META = /[<>'"&]|innerHTML|outerHTML|document\.write/;
|
|
168
|
+
const _SHELL_META = /[;|`$(){}]|&&|\|\|/;
|
|
169
|
+
|
|
170
|
+
function _literalPartsOfExpr(expr) {
|
|
171
|
+
if (!expr) return [];
|
|
172
|
+
if (expr.kind === 'literal') return [String(expr.value || '')];
|
|
173
|
+
if (expr.kind === 'tpl') return (expr.parts || []).filter(p => p.kind === 'literal').map(p => String(p.value || ''));
|
|
174
|
+
if (expr.kind === 'binary') return [..._literalPartsOfExpr(expr.left), ..._literalPartsOfExpr(expr.right)];
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function literalSkeletonMatchesFamily(expr, cwe) {
|
|
179
|
+
const literals = _literalPartsOfExpr(expr);
|
|
180
|
+
if (!literals.length) return true;
|
|
181
|
+
const joined = literals.join(' ');
|
|
182
|
+
if (!joined.trim()) return true;
|
|
183
|
+
if (cwe === 'CWE-89' || cwe === 'CWE-943') return _SQL_KEYWORDS.test(joined);
|
|
184
|
+
if (cwe === 'CWE-79') return _HTML_META.test(joined);
|
|
185
|
+
if (cwe === 'CWE-78') return _SHELL_META.test(joined);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
168
190
|
function step(node, stateIn, callContext) {
|
|
169
191
|
const state = new Set(stateIn);
|
|
@@ -177,9 +199,13 @@ function step(node, stateIn, callContext) {
|
|
|
177
199
|
return { state, findings };
|
|
178
200
|
|
|
179
201
|
case 'assign': {
|
|
180
|
-
// Source detection on RHS.
|
|
181
202
|
const src = exprIsSource(node.source);
|
|
182
203
|
const target = typeof node.target === 'string' ? node.target : null;
|
|
204
|
+
// Constant propagation: track variables assigned from literals
|
|
205
|
+
if (target && _activeConstantVars) {
|
|
206
|
+
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
207
|
+
else _activeConstantVars.delete(target);
|
|
208
|
+
}
|
|
183
209
|
let newState = state;
|
|
184
210
|
// Premortem #7: interprocedural return-taint via SummaryCache. If the
|
|
185
211
|
// RHS is a call to a known callee whose empty-entry-state summary says
|
|
@@ -340,6 +366,8 @@ function step(node, stateIn, callContext) {
|
|
|
340
366
|
const taintedArgIdx = e.argIndex === 'all'
|
|
341
367
|
? argTaints.findIndex(Boolean) : e.argIndex;
|
|
342
368
|
const taintedArgExpr = (node.args || [])[taintedArgIdx];
|
|
369
|
+
// String content analysis: skip if literal skeleton doesn't match injection family
|
|
370
|
+
if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
|
|
343
371
|
// Premortem #10: attribute the source for THIS sink to the
|
|
344
372
|
// source(s) that taint the actual argument expression — not the
|
|
345
373
|
// first source the worklist happened to record. We walk the
|
|
@@ -438,12 +466,13 @@ function step(node, stateIn, callContext) {
|
|
|
438
466
|
// every 100 iterations. A pathological CFG (large generated file with dense
|
|
439
467
|
// control flow) can otherwise hold past the global timeout.
|
|
440
468
|
function analyzeFunction(fn, entryState, callContext) {
|
|
441
|
-
const nodes = fn.cfg.nodes;
|
|
469
|
+
const nodes = fn.cfg.nodes;
|
|
442
470
|
const work = [];
|
|
443
|
-
const inStates = new Map();
|
|
471
|
+
const inStates = new Map();
|
|
444
472
|
const outStates = new Map();
|
|
445
473
|
inStates.set(fn.cfg.entry, new Set(entryState));
|
|
446
474
|
work.push(fn.cfg.entry);
|
|
475
|
+
_activeConstantVars = new Map();
|
|
447
476
|
// v0.70 #2 — points-to context for the step() transfer. Setting it here
|
|
448
477
|
// (instead of plumbing through step's signature) keeps the worklist loop
|
|
449
478
|
// unchanged and lets `step` consult `aliasesForVar` when callContext._pointsTo
|
|
@@ -712,6 +741,21 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
712
741
|
}
|
|
713
742
|
}
|
|
714
743
|
// v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
|
|
744
|
+
// Dead code suppression: demote findings in functions with zero callers
|
|
745
|
+
// (except route handlers which are entry points)
|
|
746
|
+
const calledQids = new Set();
|
|
747
|
+
if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
|
|
748
|
+
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
|
|
749
|
+
for (const f of all) {
|
|
750
|
+
if (!f._funcQid) continue;
|
|
751
|
+
const fn = callGraph.functions?.get(f._funcQid);
|
|
752
|
+
if (!fn) continue;
|
|
753
|
+
if (calledQids.has(f._funcQid)) continue;
|
|
754
|
+
if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
755
|
+
f._inDeadCode = true;
|
|
756
|
+
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
757
|
+
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
758
|
+
}
|
|
715
759
|
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
716
760
|
return all;
|
|
717
761
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Formal memory-safety verification — Recommendation #5 of the
|
|
2
|
+
// world-class+2 plan.
|
|
3
|
+
//
|
|
4
|
+
// For top-N C/C++ findings (buffer-overflow / UAF / double-free / null-
|
|
5
|
+
// deref) and top-N Rust findings (unsafe block soundness), hand the
|
|
6
|
+
// affected function off to a real bounded model checker (CBMC for C/C++,
|
|
7
|
+
// MIRI for Rust). Returns a structured verdict:
|
|
8
|
+
//
|
|
9
|
+
// { tool: 'cbmc' | 'miri', verdict: 'proved-unsafe' | 'proved-safe' |
|
|
10
|
+
// 'unknown', witness?, counterexample?, elapsedMs }
|
|
11
|
+
//
|
|
12
|
+
// Findings with verdict 'proved-unsafe' get composite-risk bumped to
|
|
13
|
+
// critical AND the counterexample attached so the dev sees an actual
|
|
14
|
+
// failing assignment. Findings 'proved-safe' get DEMOTED to info (they
|
|
15
|
+
// pass formal checking under bounded unrolling).
|
|
16
|
+
//
|
|
17
|
+
// External tooling is invoked lazily — the scanner stays bootable when
|
|
18
|
+
// CBMC / MIRI aren't installed. Gated by AGENTIC_SECURITY_FORMAL=1.
|
|
19
|
+
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
21
|
+
import { promisify } from 'node:util';
|
|
22
|
+
import * as fs from 'node:fs/promises';
|
|
23
|
+
import * as os from 'node:os';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CBMC_TIMEOUT_MS = 60_000;
|
|
29
|
+
const DEFAULT_MIRI_TIMEOUT_MS = 60_000;
|
|
30
|
+
const DEFAULT_WALL_BUDGET_MS = 300_000;
|
|
31
|
+
const DEFAULT_MAX_OBLIGATIONS = 10;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns true if CBMC is available on PATH.
|
|
35
|
+
*/
|
|
36
|
+
async function _cbmcAvailable() {
|
|
37
|
+
try {
|
|
38
|
+
await execFileAsync('cbmc', ['--version'], { timeout: 5000 });
|
|
39
|
+
return true;
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns true if Cargo + MIRI are available on PATH.
|
|
45
|
+
*/
|
|
46
|
+
async function _miriAvailable() {
|
|
47
|
+
try {
|
|
48
|
+
await execFileAsync('cargo', ['miri', '--version'], { timeout: 5000 });
|
|
49
|
+
return true;
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Discharge a C/C++ finding via CBMC. Extracts the surrounding function
|
|
55
|
+
* source, generates a CBMC harness with bounded unrolling, runs CBMC,
|
|
56
|
+
* and parses the verdict from CBMC's output.
|
|
57
|
+
*/
|
|
58
|
+
export async function dischargeCbmc(finding, sourceContent, opts = {}) {
|
|
59
|
+
if (!await _cbmcAvailable()) return { tool: 'cbmc', verdict: 'unknown', reason: 'cbmc-not-installed' };
|
|
60
|
+
const timeout = opts.timeoutMs || DEFAULT_CBMC_TIMEOUT_MS;
|
|
61
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'cbmc-'));
|
|
62
|
+
try {
|
|
63
|
+
// Best-effort function extraction — write the surrounding 50 lines
|
|
64
|
+
// around the finding's line as the proof harness.
|
|
65
|
+
const lines = sourceContent.split('\n');
|
|
66
|
+
const start = Math.max(0, finding.line - 30);
|
|
67
|
+
const end = Math.min(lines.length, finding.line + 30);
|
|
68
|
+
const fnSlice = lines.slice(start, end).join('\n');
|
|
69
|
+
const harness = `
|
|
70
|
+
#include <stdint.h>
|
|
71
|
+
#include <stdlib.h>
|
|
72
|
+
#include <string.h>
|
|
73
|
+
extern uint32_t nondet_uint32(void);
|
|
74
|
+
extern const char *nondet_str(void);
|
|
75
|
+
${fnSlice}
|
|
76
|
+
|
|
77
|
+
int main(void) {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
const filePath = path.join(tmp, 'harness.c');
|
|
82
|
+
await fs.writeFile(filePath, harness);
|
|
83
|
+
const start_ms = Date.now();
|
|
84
|
+
let stdout = '', stderr = '';
|
|
85
|
+
try {
|
|
86
|
+
const r = await execFileAsync('cbmc',
|
|
87
|
+
['--bounds-check', '--pointer-check', '--memory-leak-check',
|
|
88
|
+
'--unwind', '8', '--object-bits', '16', filePath],
|
|
89
|
+
{ timeout, maxBuffer: 8 * 1024 * 1024 });
|
|
90
|
+
stdout = r.stdout || '';
|
|
91
|
+
stderr = r.stderr || '';
|
|
92
|
+
} catch (e) {
|
|
93
|
+
stdout = (e && e.stdout) || '';
|
|
94
|
+
stderr = (e && e.stderr) || '';
|
|
95
|
+
}
|
|
96
|
+
const elapsed = Date.now() - start_ms;
|
|
97
|
+
// CBMC verdict parsing — looks for "VERIFICATION FAILED" / "VERIFICATION SUCCESSFUL"
|
|
98
|
+
if (/VERIFICATION SUCCESSFUL/i.test(stdout)) return { tool: 'cbmc', verdict: 'proved-safe', elapsedMs: elapsed };
|
|
99
|
+
if (/VERIFICATION FAILED/i.test(stdout)) {
|
|
100
|
+
const ce = (stdout.match(/Counterexample[\s\S]{0,2000}/i) || [])[0] || null;
|
|
101
|
+
return { tool: 'cbmc', verdict: 'proved-unsafe', counterexample: ce, elapsedMs: elapsed };
|
|
102
|
+
}
|
|
103
|
+
return { tool: 'cbmc', verdict: 'unknown', reason: stderr.slice(0, 200), elapsedMs: elapsed };
|
|
104
|
+
} finally {
|
|
105
|
+
try { await fs.rm(tmp, { recursive: true, force: true }); } catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Discharge a Rust unsafe-block finding via MIRI. Compiles + runs the
|
|
111
|
+
* file under MIRI, which interprets the program and flags any undefined
|
|
112
|
+
* behavior (UAF, OOB access, uninitialized read, etc.).
|
|
113
|
+
*
|
|
114
|
+
* Requires the source to be a complete Cargo project; in v1 we generate
|
|
115
|
+
* a minimal Cargo project around the function in question.
|
|
116
|
+
*/
|
|
117
|
+
export async function dischargeMiri(finding, sourceContent, opts = {}) {
|
|
118
|
+
if (!await _miriAvailable()) return { tool: 'miri', verdict: 'unknown', reason: 'miri-not-installed' };
|
|
119
|
+
const timeout = opts.timeoutMs || DEFAULT_MIRI_TIMEOUT_MS;
|
|
120
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'miri-'));
|
|
121
|
+
try {
|
|
122
|
+
await fs.mkdir(path.join(tmp, 'src'), { recursive: true });
|
|
123
|
+
await fs.writeFile(path.join(tmp, 'Cargo.toml'), `[package]
|
|
124
|
+
name = "miri-harness"
|
|
125
|
+
version = "0.1.0"
|
|
126
|
+
edition = "2021"
|
|
127
|
+
|
|
128
|
+
[[bin]]
|
|
129
|
+
name = "miri-harness"
|
|
130
|
+
path = "src/main.rs"
|
|
131
|
+
`);
|
|
132
|
+
// Best-effort: paste the function and call it with a small bounded
|
|
133
|
+
// input. Real integration would use rust-analyzer's call graph.
|
|
134
|
+
const lines = sourceContent.split('\n');
|
|
135
|
+
const start = Math.max(0, finding.line - 30);
|
|
136
|
+
const end = Math.min(lines.length, finding.line + 30);
|
|
137
|
+
const fnSlice = lines.slice(start, end).join('\n');
|
|
138
|
+
const harness = `${fnSlice}\nfn main() {}\n`;
|
|
139
|
+
await fs.writeFile(path.join(tmp, 'src', 'main.rs'), harness);
|
|
140
|
+
const start_ms = Date.now();
|
|
141
|
+
let stdout = '', stderr = '';
|
|
142
|
+
try {
|
|
143
|
+
const r = await execFileAsync('cargo', ['miri', 'run'], { cwd: tmp, timeout, maxBuffer: 8 * 1024 * 1024 });
|
|
144
|
+
stdout = r.stdout || ''; stderr = r.stderr || '';
|
|
145
|
+
} catch (e) {
|
|
146
|
+
stdout = (e && e.stdout) || ''; stderr = (e && e.stderr) || '';
|
|
147
|
+
}
|
|
148
|
+
const elapsed = Date.now() - start_ms;
|
|
149
|
+
const combined = stdout + '\n' + stderr;
|
|
150
|
+
// MIRI flags UB with "error: Undefined Behavior:"
|
|
151
|
+
if (/error:\s*Undefined Behavior:/i.test(combined)) {
|
|
152
|
+
const where = (combined.match(/error:\s*Undefined Behavior:[\s\S]{0,1000}/i) || [])[0] || null;
|
|
153
|
+
return { tool: 'miri', verdict: 'proved-unsafe', counterexample: where, elapsedMs: elapsed };
|
|
154
|
+
}
|
|
155
|
+
if (/^[\s\S]*$/.test(combined) && !/error/i.test(combined)) {
|
|
156
|
+
return { tool: 'miri', verdict: 'proved-safe', elapsedMs: elapsed };
|
|
157
|
+
}
|
|
158
|
+
return { tool: 'miri', verdict: 'unknown', reason: combined.slice(0, 200), elapsedMs: elapsed };
|
|
159
|
+
} finally {
|
|
160
|
+
try { await fs.rm(tmp, { recursive: true, force: true }); } catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Bulk-annotate findings with formal verification results. Adds a
|
|
166
|
+
* `formalVerification` field with the verdict + witness. Demotes
|
|
167
|
+
* 'proved-safe' findings; bumps 'proved-unsafe' to critical.
|
|
168
|
+
*/
|
|
169
|
+
export async function annotateFormalVerification(findings, fileContents, opts = {}) {
|
|
170
|
+
if (!Array.isArray(findings)) return { processed: 0, bumped: 0, demoted: 0 };
|
|
171
|
+
if (process.env.AGENTIC_SECURITY_FORMAL !== '1') return { skipped: true };
|
|
172
|
+
const max = opts.maxObligations || DEFAULT_MAX_OBLIGATIONS;
|
|
173
|
+
const walltime = opts.walltimeMs || DEFAULT_WALL_BUDGET_MS;
|
|
174
|
+
const eligible = findings
|
|
175
|
+
.filter(f => f.severity === 'critical' || f.severity === 'high')
|
|
176
|
+
.filter(f => f.family === 'buffer-overflow' || f.family === 'mem-unsafe' ||
|
|
177
|
+
(f.parser === 'RUST' && f.family === 'unsafe-block'))
|
|
178
|
+
.slice(0, max);
|
|
179
|
+
const start = Date.now();
|
|
180
|
+
let processed = 0, bumped = 0, demoted = 0;
|
|
181
|
+
for (const f of eligible) {
|
|
182
|
+
if (Date.now() - start > walltime) break;
|
|
183
|
+
const src = fileContents?.[f.file];
|
|
184
|
+
if (!src) continue;
|
|
185
|
+
const res = (f.parser === 'RUST')
|
|
186
|
+
? await dischargeMiri(f, src, opts)
|
|
187
|
+
: await dischargeCbmc(f, src, opts);
|
|
188
|
+
f.formalVerification = res;
|
|
189
|
+
processed++;
|
|
190
|
+
if (res.verdict === 'proved-unsafe' && f.severity !== 'critical') {
|
|
191
|
+
f._formalBump = f.severity;
|
|
192
|
+
f.severity = 'critical';
|
|
193
|
+
bumped++;
|
|
194
|
+
}
|
|
195
|
+
if (res.verdict === 'proved-safe') {
|
|
196
|
+
f._formalDemote = f.severity;
|
|
197
|
+
f.severity = 'info';
|
|
198
|
+
demoted++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { processed, bumped, demoted, elapsedMs: Date.now() - start };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const _internals = { _cbmcAvailable, _miriAvailable, DEFAULT_CBMC_TIMEOUT_MS, DEFAULT_MIRI_TIMEOUT_MS };
|