@clear-capabilities/agentic-security-scanner 0.152.0 → 0.153.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,189 +0,0 @@
1
- // SMT path feasibility — Recommendation #3 of the world-class roadmap.
2
- //
3
- // For top-N findings per scan, generate SMT constraints from the IR
4
- // representing the conditions that must hold along the call-graph path
5
- // from source to sink. Discharge via a Z3 solver. If UNSAT, the
6
- // finding is provably infeasible and gets demoted to 'info' severity
7
- // with `pathFeasibility: 'unsat'`. If SAT, we emit a sample witness
8
- // (a concrete tainted input that triggers the sink) which is gold-standard
9
- // evidence for the developer.
10
- //
11
- // Solver backend: prefers `z3-solver` (Z3 WASM published on npm) when
12
- // installed; falls back to a constraint-emission-only mode that still
13
- // records the SMT-LIB script so a CI step can discharge it offline.
14
- //
15
- // Gating: opt-in via AGENTIC_SECURITY_SMT_FEASIBILITY=1. Always bounded
16
- // at top-MAX_PROOF_OBLIGATIONS findings per scan to keep wall-clock
17
- // under PROOF_BUDGET_MS.
18
- //
19
- // IMPORTANT — this module is NOT a generic symbolic executor. It targets
20
- // a narrow shape: "does there exist an input that flows from source S
21
- // through path P to sink K?" That's enough to prove or refute the
22
- // reachability claim on a finding the engine already produced. We do
23
- // NOT attempt to prove arbitrary safety properties.
24
-
25
- const PROOF_BUDGET_MS_DEFAULT = 30_000;
26
- const MAX_PROOF_OBLIGATIONS_DEFAULT = 50;
27
- const PER_QUERY_TIMEOUT_MS_DEFAULT = 5_000;
28
-
29
- // Lazy-load Z3. The module is permitted to be absent — when it is, we
30
- // fall back to constraint-emission-only mode (the SMT-LIB script is
31
- // attached to the finding for offline discharge).
32
- let _z3Mod = null;
33
- let _z3LoadAttempted = false;
34
- async function _loadZ3() {
35
- if (_z3LoadAttempted) return _z3Mod;
36
- _z3LoadAttempted = true;
37
- try {
38
- _z3Mod = await import('z3-solver');
39
- if (typeof _z3Mod.init === 'function') await _z3Mod.init();
40
- } catch { _z3Mod = null; }
41
- return _z3Mod;
42
- }
43
-
44
- // ── Constraint emission ───────────────────────────────────────────────────
45
-
46
- /**
47
- * Encode a single IR predicate (one node along the path) into an SMT-LIB
48
- * assertion. Predicates supported in v1:
49
- * - `var = source(name)` — declares var as a free symbolic string
50
- * - `var = const(literal)` — equality with a constant
51
- * - `var = concat(a, b)` — string concatenation
52
- * - `var = sanitize(x, kind)` — applies a sanitizer; encoded as
53
- * `var = "safe"` (forces concrete)
54
- * - `assert reach(line N)` — terminal predicate: this line must be
55
- * reachable
56
- * - `guard(cond)` — a path condition (free-form text)
57
- */
58
- function encodePredicate(p, idx) {
59
- switch (p.kind) {
60
- case 'source':
61
- return `(declare-const ${p.var} String)`;
62
- case 'const':
63
- return `(assert (= ${p.var} ${JSON.stringify(p.value)}))`;
64
- case 'concat':
65
- return `(assert (= ${p.var} (str.++ ${p.a} ${p.b})))`;
66
- case 'sanitize':
67
- return `(assert (= ${p.var} "safe-${p.kind}-${idx}"))`;
68
- case 'reach':
69
- // Symbolic "this line is reached" — we don't really model reachability,
70
- // we just record the obligation. The presence of the path is what
71
- // matters; SAT just means "some input satisfies the path conditions."
72
- return `; reach(${p.file}:${p.line})`;
73
- case 'guard':
74
- return `(assert ${p.smtCond || `(= ${p.var} ${JSON.stringify(p.value)})`})`;
75
- default:
76
- return `; unsupported predicate kind: ${p.kind}`;
77
- }
78
- }
79
-
80
- /**
81
- * Emit a complete SMT-LIB script for one finding. The script declares
82
- * source variables, asserts every predicate, asks (check-sat). On SAT
83
- * we (get-model) for the witness; on UNSAT the finding is infeasible.
84
- */
85
- export function emitSmtScript(predicates, opts = {}) {
86
- const lines = [];
87
- lines.push('; SMT-LIB script — emitted by scanner/src/dataflow/smt-feasibility.js');
88
- lines.push(`(set-logic QF_S)`);
89
- lines.push(`(set-option :timeout ${opts.timeoutMs || PER_QUERY_TIMEOUT_MS_DEFAULT})`);
90
- predicates.forEach((p, i) => lines.push(encodePredicate(p, i)));
91
- lines.push('(check-sat)');
92
- lines.push('(get-model)');
93
- return lines.join('\n');
94
- }
95
-
96
- // ── Z3 discharge ──────────────────────────────────────────────────────────
97
-
98
- /**
99
- * dischargeFinding(predicates, opts) — encode + solve. Returns one of:
100
- * { verdict: 'sat', witness: { var: value } }
101
- * { verdict: 'unsat' }
102
- * { verdict: 'unknown', reason: '<why>' }
103
- * { verdict: 'pending', script: '<smt-lib text>' } // when Z3 unavailable
104
- */
105
- export async function dischargeFinding(predicates, opts = {}) {
106
- if (!predicates || !predicates.length) return { verdict: 'unknown', reason: 'no-predicates' };
107
- const script = emitSmtScript(predicates, opts);
108
- const z3 = await _loadZ3();
109
- if (!z3) return { verdict: 'pending', script };
110
- try {
111
- const { Context } = z3;
112
- const ctx = new Context('main');
113
- const solver = new ctx.Solver();
114
- // Feed the script via parse — z3-solver supports SMT-LIB ingestion.
115
- try { solver.fromString(script); }
116
- catch (e) {
117
- return { verdict: 'unknown', reason: 'parse-error: ' + String(e && e.message), script };
118
- }
119
- const start = Date.now();
120
- const result = await Promise.race([
121
- solver.check(),
122
- new Promise(resolve => setTimeout(() => resolve('timeout'), opts.timeoutMs || PER_QUERY_TIMEOUT_MS_DEFAULT)),
123
- ]);
124
- const elapsed = Date.now() - start;
125
- if (result === 'unsat') return { verdict: 'unsat', elapsedMs: elapsed };
126
- if (result === 'timeout' || result === 'unknown') return { verdict: 'unknown', reason: result, elapsedMs: elapsed, script };
127
- if (result === 'sat') {
128
- // Best-effort witness extraction.
129
- let witness = {};
130
- try {
131
- const model = solver.model();
132
- for (const decl of model.decls()) witness[decl.name()] = String(model.get(decl));
133
- } catch { /* no model */ }
134
- return { verdict: 'sat', witness, elapsedMs: elapsed };
135
- }
136
- return { verdict: 'unknown', reason: String(result), elapsedMs: elapsed };
137
- } catch (e) {
138
- return { verdict: 'unknown', reason: String(e && e.message || e), script };
139
- }
140
- }
141
-
142
- // ── Finding-level integration ─────────────────────────────────────────────
143
-
144
- /**
145
- * Annotate the top-N findings with their feasibility verdict. Modifies
146
- * findings in place — each gets a `pathFeasibility` field and (when
147
- * SAT) a `feasibilityWitness` object. Findings whose verdict is UNSAT
148
- * are demoted to 'info' severity.
149
- */
150
- export async function annotatePathFeasibility(findings, opts = {}) {
151
- if (!Array.isArray(findings)) return { annotated: 0, demoted: 0 };
152
- const budget = opts.budgetMs || PROOF_BUDGET_MS_DEFAULT;
153
- const max = opts.maxObligations || MAX_PROOF_OBLIGATIONS_DEFAULT;
154
- // Prioritize: critical/high findings with concrete chains first.
155
- const sorted = [...findings]
156
- .filter(f => f.severity === 'critical' || f.severity === 'high')
157
- .filter(f => Array.isArray(f.chain) || Array.isArray(f.taintPath))
158
- .slice(0, max);
159
- const start = Date.now();
160
- let annotated = 0, demoted = 0;
161
- for (const f of sorted) {
162
- if (Date.now() - start > budget) {
163
- f.pathFeasibility = 'unknown';
164
- f.feasibilityReason = 'budget-exceeded';
165
- continue;
166
- }
167
- const predicates = (f.chain || f.taintPath || []).map((step, i) => ({
168
- kind: i === 0 ? 'source' : (step.kind || 'concat'),
169
- var: `v${i}`,
170
- a: `v${Math.max(0, i - 1)}`, b: '""',
171
- value: step.value || '',
172
- file: step.file, line: step.line,
173
- }));
174
- const r = await dischargeFinding(predicates, { timeoutMs: Math.min(5_000, budget) });
175
- f.pathFeasibility = r.verdict;
176
- if (r.witness) f.feasibilityWitness = r.witness;
177
- if (r.script) f._smtScript = r.script.slice(0, 4000);
178
- annotated++;
179
- if (r.verdict === 'unsat') {
180
- const before = f.severity;
181
- f.severity = 'info';
182
- f._pathFeasibilityDemoted = before;
183
- demoted++;
184
- }
185
- }
186
- return { annotated, demoted, elapsedMs: Date.now() - start };
187
- }
188
-
189
- export const _internals = { encodePredicate, emitSmtScript, PROOF_BUDGET_MS_DEFAULT, MAX_PROOF_OBLIGATIONS_DEFAULT };