@clear-capabilities/agentic-security-scanner 0.79.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/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -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 +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- 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 +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- 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/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/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- 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/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/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- 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/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- 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/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/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -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 };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// IFDS-precise extensions — Recommendation #2 of the world-class roadmap.
|
|
2
|
+
//
|
|
3
|
+
// The existing scanner/src/dataflow/ifds.js implements the core IFDS
|
|
4
|
+
// worklist algorithm with k=1 summarized return-taint. This module adds
|
|
5
|
+
// the three world-class pieces still missing:
|
|
6
|
+
//
|
|
7
|
+
// 1. Per-call-site summary REFINEMENT — instead of "this function
|
|
8
|
+
// returns tainted unconditionally," cache "returns tainted under
|
|
9
|
+
// entry state X" so the same callee at different sites uses
|
|
10
|
+
// different summaries.
|
|
11
|
+
// 2. On-demand BACKWARD SLICING for high-confidence findings —
|
|
12
|
+
// starting from a critical sink, walk backwards through the
|
|
13
|
+
// use-def chain and emit a minimal trace that explains exactly
|
|
14
|
+
// which lines contribute taint.
|
|
15
|
+
// 3. PERSISTENT cross-scan summary cache — write the summary table
|
|
16
|
+
// to .agentic-security/ifds-summaries.json after each scan and
|
|
17
|
+
// reload on the next scan. Skip re-analysis of unchanged
|
|
18
|
+
// functions (incremental analysis).
|
|
19
|
+
//
|
|
20
|
+
// Opt-in via AGENTIC_SECURITY_IFDS_PRECISE=1 alongside the existing
|
|
21
|
+
// AGENTIC_SECURITY_DEEP=1.
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import * as crypto from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
// ── Per-call-site refined summaries ────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* RefinedSummaryCache — extends the base summary cache with per-entry-state
|
|
31
|
+
* refinement. Whereas the base cache stores ONE summary per function under
|
|
32
|
+
* empty entry state, this layer caches a MAP of (entryStateHash → summary)
|
|
33
|
+
* per function.
|
|
34
|
+
*
|
|
35
|
+
* The intent: at call site A→B(x), the entry state captures which of B's
|
|
36
|
+
* formal parameters are tainted by A's actual argument expressions. If x
|
|
37
|
+
* is tainted at site 1 but not at site 2, we cache TWO summaries for B,
|
|
38
|
+
* and the caller's worklist consults the right one.
|
|
39
|
+
*
|
|
40
|
+
* Capped at MAX_REFINEMENTS_PER_FN to keep cache size bounded.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_REFINEMENTS_PER_FN = 4;
|
|
43
|
+
|
|
44
|
+
export class RefinedSummaryCache {
|
|
45
|
+
constructor(baseCache, opts = {}) {
|
|
46
|
+
this._base = baseCache;
|
|
47
|
+
this._refinements = new Map(); // qid → Map<stateHash, summary>
|
|
48
|
+
this._lru = new Map(); // qid → array (recency)
|
|
49
|
+
this.maxPerFn = opts.maxPerFn || MAX_REFINEMENTS_PER_FN;
|
|
50
|
+
this.metrics = { refinementHits: 0, refinementMisses: 0, refinementEvictions: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_hash(entryState) {
|
|
54
|
+
if (!entryState) return '∅';
|
|
55
|
+
if (entryState instanceof Set) {
|
|
56
|
+
if (entryState.size === 0) return '∅';
|
|
57
|
+
return [...entryState].sort().join('|');
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(entryState)) {
|
|
60
|
+
if (entryState.length === 0) return '∅';
|
|
61
|
+
return entryState.slice().sort().join('|');
|
|
62
|
+
}
|
|
63
|
+
if (typeof entryState === 'object') {
|
|
64
|
+
// Object keyed by parameter index → tainted bool.
|
|
65
|
+
const keys = Object.keys(entryState).sort();
|
|
66
|
+
return keys.map(k => `${k}=${entryState[k] ? 1 : 0}`).join(',') || '∅';
|
|
67
|
+
}
|
|
68
|
+
return String(entryState);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get(qid, entryState) {
|
|
72
|
+
const h = this._hash(entryState);
|
|
73
|
+
const m = this._refinements.get(qid);
|
|
74
|
+
if (m && m.has(h)) {
|
|
75
|
+
this._touch(qid, h);
|
|
76
|
+
this.metrics.refinementHits++;
|
|
77
|
+
return m.get(h);
|
|
78
|
+
}
|
|
79
|
+
// Fallback to base for empty entry state (matches k=1 behavior).
|
|
80
|
+
if (this._base && typeof this._base.get === 'function') {
|
|
81
|
+
const v = this._base.get(qid, entryState);
|
|
82
|
+
if (v) return v;
|
|
83
|
+
}
|
|
84
|
+
this.metrics.refinementMisses++;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
store(qid, entryState, summary) {
|
|
89
|
+
const h = this._hash(entryState);
|
|
90
|
+
let m = this._refinements.get(qid);
|
|
91
|
+
let order = this._lru.get(qid);
|
|
92
|
+
if (!m) { m = new Map(); this._refinements.set(qid, m); }
|
|
93
|
+
if (!order) { order = []; this._lru.set(qid, order); }
|
|
94
|
+
if (!m.has(h)) {
|
|
95
|
+
while (order.length >= this.maxPerFn) {
|
|
96
|
+
const evict = order.shift();
|
|
97
|
+
m.delete(evict);
|
|
98
|
+
this.metrics.refinementEvictions++;
|
|
99
|
+
}
|
|
100
|
+
order.push(h);
|
|
101
|
+
}
|
|
102
|
+
m.set(h, summary);
|
|
103
|
+
// Also seed base for the empty-entry path.
|
|
104
|
+
if ((entryState instanceof Set && entryState.size === 0) && this._base && typeof this._base.set === 'function') {
|
|
105
|
+
try { this._base.set(qid, new Set(), summary); } catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_touch(qid, h) {
|
|
110
|
+
const order = this._lru.get(qid);
|
|
111
|
+
if (!order) return;
|
|
112
|
+
const idx = order.indexOf(h);
|
|
113
|
+
if (idx >= 0) { order.splice(idx, 1); order.push(h); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
size() {
|
|
117
|
+
let n = 0;
|
|
118
|
+
for (const m of this._refinements.values()) n += m.size;
|
|
119
|
+
return n;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── On-demand backward slicing ─────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* backwardSlice(callGraph, finding) — given a finding at a sink, walk
|
|
127
|
+
* backwards through use-def edges to produce a minimal trace explaining
|
|
128
|
+
* each step from source to sink. Returns an array of { line, file,
|
|
129
|
+
* snippet, reason } entries ordered source-first.
|
|
130
|
+
*
|
|
131
|
+
* The traversal is intentionally bounded (depth ≤ MAX_SLICE_DEPTH) and
|
|
132
|
+
* cycle-aware. For very deep flows we emit a `...` elision rather than
|
|
133
|
+
* unbounded growth.
|
|
134
|
+
*/
|
|
135
|
+
const MAX_SLICE_DEPTH = 16;
|
|
136
|
+
|
|
137
|
+
export function backwardSlice(callGraph, finding, opts = {}) {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const out = [];
|
|
140
|
+
if (!finding) return out;
|
|
141
|
+
let cur = finding.sink || finding;
|
|
142
|
+
let depth = 0;
|
|
143
|
+
while (cur && depth < MAX_SLICE_DEPTH) {
|
|
144
|
+
const key = `${cur.file || finding.file}:${cur.line}`;
|
|
145
|
+
if (seen.has(key)) { out.push({ ...cur, reason: 'cycle-detected' }); break; }
|
|
146
|
+
seen.add(key);
|
|
147
|
+
out.push({
|
|
148
|
+
file: cur.file || finding.file,
|
|
149
|
+
line: cur.line,
|
|
150
|
+
snippet: cur.snippet || cur.expr || null,
|
|
151
|
+
reason: cur.reason || 'use-def-pred',
|
|
152
|
+
});
|
|
153
|
+
cur = cur.predecessor || (callGraph && callGraph.getPred && callGraph.getPred(cur)) || null;
|
|
154
|
+
depth++;
|
|
155
|
+
}
|
|
156
|
+
if (depth >= MAX_SLICE_DEPTH) out.push({ reason: 'slice-depth-cap' });
|
|
157
|
+
return out.reverse(); // source-first
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Persistent cross-scan summary cache ────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function _cachePath(scanRoot) {
|
|
163
|
+
return path.join(scanRoot, '.agentic-security', 'ifds-summaries.json');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _fileHash(content) {
|
|
167
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Load a previously-persisted IFDS summary cache. Returns:
|
|
172
|
+
* { summaries: Map<qid, summary>, fileHashes: Map<filePath, sha>, scanTs }
|
|
173
|
+
* or null if no persisted cache exists / is unreadable.
|
|
174
|
+
*/
|
|
175
|
+
export function loadPersistedCache(scanRoot) {
|
|
176
|
+
const fp = _cachePath(scanRoot);
|
|
177
|
+
if (!fs.existsSync(fp)) return null;
|
|
178
|
+
try {
|
|
179
|
+
const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
180
|
+
return {
|
|
181
|
+
summaries: new Map(Object.entries(raw.summaries || {})),
|
|
182
|
+
fileHashes: new Map(Object.entries(raw.fileHashes || {})),
|
|
183
|
+
scanTs: raw.scanTs || null,
|
|
184
|
+
};
|
|
185
|
+
} catch { return null; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Persist the current scan's summaries to disk. Subsequent scans can
|
|
190
|
+
* skip re-analysis of functions whose file hash hasn't changed.
|
|
191
|
+
*/
|
|
192
|
+
export function persistCache(scanRoot, cache, perFileIR) {
|
|
193
|
+
const dir = path.join(scanRoot, '.agentic-security');
|
|
194
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
195
|
+
const fileHashes = {};
|
|
196
|
+
for (const [filePath, ir] of (perFileIR || new Map())) {
|
|
197
|
+
if (ir && typeof ir._content === 'string') fileHashes[filePath] = _fileHash(ir._content);
|
|
198
|
+
}
|
|
199
|
+
const summaries = {};
|
|
200
|
+
for (const [qid, sum] of (cache._refinements || new Map())) {
|
|
201
|
+
// Serialize only the empty-entry-state summary — the refinements are
|
|
202
|
+
// ephemeral per scan; the empty-entry summary is the stable contract.
|
|
203
|
+
if (sum.has('∅')) summaries[qid] = sum.get('∅');
|
|
204
|
+
}
|
|
205
|
+
const out = { scanTs: new Date().toISOString(), summaries, fileHashes };
|
|
206
|
+
try { fs.writeFileSync(_cachePath(scanRoot), JSON.stringify(out, null, 2)); }
|
|
207
|
+
catch { /* best-effort */ }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Skip analysis of an unchanged function — when the file containing the
|
|
212
|
+
* function hasn't changed since the last persisted cache, reuse the prior
|
|
213
|
+
* summary.
|
|
214
|
+
*/
|
|
215
|
+
export function shouldSkipReanalysis(prevCache, filePath, currentContent) {
|
|
216
|
+
if (!prevCache || !prevCache.fileHashes) return false;
|
|
217
|
+
const prevHash = prevCache.fileHashes.get(filePath);
|
|
218
|
+
if (!prevHash) return false;
|
|
219
|
+
return prevHash === _fileHash(currentContent);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const _internals = { _cachePath, _fileHash, MAX_REFINEMENTS_PER_FN, MAX_SLICE_DEPTH };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// k=2 monovariant summary cache — Recommendation #9 of the SCA/SAST plan.
|
|
2
|
+
//
|
|
3
|
+
// The existing scanner/src/dataflow/summaries.js (referenced by engine.js)
|
|
4
|
+
// implements k=1: per-function ONE summary computed under empty entry state.
|
|
5
|
+
// That misses the common Juliet pattern of "function is pure when called
|
|
6
|
+
// with clean args but vulnerable when called with tainted args" because
|
|
7
|
+
// only the empty-state summary is cached.
|
|
8
|
+
//
|
|
9
|
+
// This module wraps SummaryCache with a per-(qid, entry-state-class) lookup,
|
|
10
|
+
// up to 2 distinct entry-state classes per function. The "class" is computed
|
|
11
|
+
// from a stable hash of which parameter positions are tainted — at k=2 we
|
|
12
|
+
// cache the all-clean state and one tainted state per function. Three or
|
|
13
|
+
// more distinct states evict to LRU.
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// const k2 = new K2SummaryCache(opts.baseCache);
|
|
17
|
+
// k2.get(qid, entryState) → summary | undefined
|
|
18
|
+
// k2.compute(qid, entryState, fn) → summary
|
|
19
|
+
// k2.applyAtCallSite(qid, entryState, callerCtx) → mutations
|
|
20
|
+
//
|
|
21
|
+
// Falls back to k=1 behaviour transparently when summaries.js's
|
|
22
|
+
// SummaryCache.get returns a summary that doesn't carry entry-state info,
|
|
23
|
+
// so the rest of the engine continues to work unchanged.
|
|
24
|
+
|
|
25
|
+
const _MAX_STATES_PER_FN = 2;
|
|
26
|
+
|
|
27
|
+
function _hashEntryState(entryState) {
|
|
28
|
+
// Stable string from a Set of "tainted parameter positions" / variable
|
|
29
|
+
// names. For k=2 we only care about taint cardinality + which positions
|
|
30
|
+
// — the actual values are not modelled (premortem: no value sensitivity
|
|
31
|
+
// until field-sensitive cache lifts in v3).
|
|
32
|
+
if (!entryState) return '∅';
|
|
33
|
+
if (entryState instanceof Set) {
|
|
34
|
+
if (entryState.size === 0) return '∅';
|
|
35
|
+
return [...entryState].sort().join(',');
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(entryState)) {
|
|
38
|
+
if (entryState.length === 0) return '∅';
|
|
39
|
+
return entryState.slice().sort().join(',');
|
|
40
|
+
}
|
|
41
|
+
// Fallback for opaque entry states — single bucket.
|
|
42
|
+
return '*';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class K2SummaryCache {
|
|
46
|
+
constructor(baseCache) {
|
|
47
|
+
this._base = baseCache; // existing k=1 cache (SummaryCache)
|
|
48
|
+
this._states = new Map(); // qid → Map<stateHash, summary>
|
|
49
|
+
this._stateOrder = new Map(); // qid → array (LRU order)
|
|
50
|
+
this.metrics = { hits: 0, misses: 0, evictions: 0, computes: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a summary for (qid, entry). Returns undefined if uncached.
|
|
55
|
+
* Falls back to the base cache when our k=2 table has no entry.
|
|
56
|
+
*/
|
|
57
|
+
get(qid, entryState) {
|
|
58
|
+
const hash = _hashEntryState(entryState);
|
|
59
|
+
const states = this._states.get(qid);
|
|
60
|
+
if (states && states.has(hash)) {
|
|
61
|
+
this.metrics.hits++;
|
|
62
|
+
this._touch(qid, hash);
|
|
63
|
+
return states.get(hash);
|
|
64
|
+
}
|
|
65
|
+
// k=1 fallback — accept whatever the base cache stored.
|
|
66
|
+
if (this._base && typeof this._base.get === 'function') {
|
|
67
|
+
const v = this._base.get(qid, entryState);
|
|
68
|
+
if (v) { this.metrics.hits++; return v; }
|
|
69
|
+
}
|
|
70
|
+
this.metrics.misses++;
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute (or retrieve) a summary for (qid, entry). Uses the supplied
|
|
76
|
+
* `compute` function only on miss. Caches per-state at k=2.
|
|
77
|
+
*/
|
|
78
|
+
compute(qid, entryState, computeFn) {
|
|
79
|
+
const existing = this.get(qid, entryState);
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
this.metrics.computes++;
|
|
82
|
+
const summary = computeFn();
|
|
83
|
+
this._store(qid, entryState, summary);
|
|
84
|
+
// Also seed the base cache under empty-entry-state so the k=1 engine
|
|
85
|
+
// paths that don't know about k=2 still see the cleanest summary.
|
|
86
|
+
if (this._base && typeof this._base.set === 'function' && (!entryState || (entryState instanceof Set && entryState.size === 0))) {
|
|
87
|
+
try { this._base.set(qid, new Set(), summary); } catch {}
|
|
88
|
+
}
|
|
89
|
+
return summary;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Apply the cached summary at a call site, propagating return-taint and
|
|
94
|
+
* mutated-parameter taint into the caller's mutation set. Mirrors the
|
|
95
|
+
* base cache's applyAtCallSite signature.
|
|
96
|
+
*/
|
|
97
|
+
applyAtCallSite(qid, entryState, callerCtx) {
|
|
98
|
+
const summary = this.get(qid, entryState);
|
|
99
|
+
if (!summary) return null;
|
|
100
|
+
// Defer to the base implementation when present — we don't reimplement
|
|
101
|
+
// the mutation algebra here.
|
|
102
|
+
if (this._base && typeof this._base.applyAtCallSite === 'function') {
|
|
103
|
+
try { return this._base.applyAtCallSite(qid, entryState, callerCtx, summary); }
|
|
104
|
+
catch { return null; }
|
|
105
|
+
}
|
|
106
|
+
return summary;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_store(qid, entryState, summary) {
|
|
110
|
+
const hash = _hashEntryState(entryState);
|
|
111
|
+
let states = this._states.get(qid);
|
|
112
|
+
let order = this._stateOrder.get(qid);
|
|
113
|
+
if (!states) { states = new Map(); this._states.set(qid, states); }
|
|
114
|
+
if (!order) { order = []; this._stateOrder.set(qid, order); }
|
|
115
|
+
if (!states.has(hash)) {
|
|
116
|
+
// LRU eviction at k=2.
|
|
117
|
+
while (order.length >= _MAX_STATES_PER_FN) {
|
|
118
|
+
const evict = order.shift();
|
|
119
|
+
states.delete(evict);
|
|
120
|
+
this.metrics.evictions++;
|
|
121
|
+
}
|
|
122
|
+
order.push(hash);
|
|
123
|
+
}
|
|
124
|
+
states.set(hash, summary);
|
|
125
|
+
}
|
|
126
|
+
_touch(qid, hash) {
|
|
127
|
+
const order = this._stateOrder.get(qid);
|
|
128
|
+
if (!order) return;
|
|
129
|
+
const idx = order.indexOf(hash);
|
|
130
|
+
if (idx >= 0) { order.splice(idx, 1); order.push(hash); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Size of the cache — for diagnostics / metrics dashboards.
|
|
135
|
+
*/
|
|
136
|
+
size() {
|
|
137
|
+
let n = 0;
|
|
138
|
+
for (const states of this._states.values()) n += states.size;
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap an existing k=1 SummaryCache with k=2 behavior. The engine can opt
|
|
145
|
+
* into this via AGENTIC_SECURITY_K2_TAINT=1.
|
|
146
|
+
*/
|
|
147
|
+
export function wrapAsK2(baseCache) {
|
|
148
|
+
if (!baseCache) return new K2SummaryCache(null);
|
|
149
|
+
if (baseCache instanceof K2SummaryCache) return baseCache;
|
|
150
|
+
return new K2SummaryCache(baseCache);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const _internals = { _hashEntryState, _MAX_STATES_PER_FN };
|