@clear-capabilities/agentic-security-scanner 0.184.0 → 0.185.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 +15 -0
- package/bin/agentic-security.js +87 -0
- package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +26 -9
- package/src/dataflow/CLAUDE.md +43 -1
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +929 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +51 -3
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/lineage/CLAUDE.md +71 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +138 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +13 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
|
@@ -35,42 +35,77 @@ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
|
|
|
35
35
|
|
|
36
36
|
const FILES_PATH = 'files.json';
|
|
37
37
|
const SUMMARIES_PATH = 'summaries.json';
|
|
38
|
-
const VERSION_PATH = 'version.json';
|
|
38
|
+
const VERSION_PATH = 'version.json'; // the MANIFEST: written last, names and digests every data file
|
|
39
|
+
const DEPS_PATH = 'deps.json'; // DAG-05: dependency model + global components
|
|
40
|
+
const REPLAY_PATH = 'replay.json'; // DAG-05: typed contexts, exit states, per-function findings
|
|
41
|
+
const DATA_FILES = [FILES_PATH, SUMMARIES_PATH, DEPS_PATH, REPLAY_PATH];
|
|
39
42
|
const MAX_PERSISTED_SUMMARIES = 50000;
|
|
43
|
+
// DAG-05 R05.6: bump when the on-disk shape changes; an older or newer record is rejected, never guessed at.
|
|
44
|
+
export const INCREMENTAL_FORMAT = 'incremental-state/2';
|
|
40
45
|
|
|
41
46
|
/** Compute the content hash used for file-equality detection. */
|
|
42
47
|
export function hashFileContent(stripped) {
|
|
43
48
|
return crypto.createHash('sha256').update(stripped || '').digest('hex');
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Read the persisted state. Returns a fresh empty state on any error.
|
|
53
|
+
*
|
|
54
|
+
* DAG-05 R05.6: the version file is a manifest that names every data file with
|
|
55
|
+
* its SHA-256. A state is accepted only when the format is the current one, every
|
|
56
|
+
* named file exists and matches its digest and parses, so a crash between the
|
|
57
|
+
* temp write and the rename, a truncated file or a hand edit is rejected as a
|
|
58
|
+
* whole. A rejection is carried as `state.rejected` (the reason) so the caller
|
|
59
|
+
* can say why it fell back to cold analysis.
|
|
60
|
+
*/
|
|
47
61
|
export function readIncrementalState(projectRoot) {
|
|
48
62
|
const dir = statePath(projectRoot, 'incremental');
|
|
63
|
+
const versionFp = path.join(dir, VERSION_PATH);
|
|
64
|
+
let manifest;
|
|
49
65
|
try {
|
|
50
|
-
const versionFp = path.join(dir, VERSION_PATH);
|
|
51
66
|
if (!fs.existsSync(versionFp)) return _emptyState();
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
version: v,
|
|
55
|
-
files: _readJsonOrEmpty(path.join(dir, FILES_PATH), {}),
|
|
56
|
-
summaries: _readJsonOrEmpty(path.join(dir, SUMMARIES_PATH), {}),
|
|
57
|
-
};
|
|
67
|
+
manifest = JSON.parse(fs.readFileSync(versionFp, 'utf8'));
|
|
58
68
|
} catch (_e) {
|
|
59
|
-
return
|
|
69
|
+
return _rejected('cache manifest is corrupt (does not parse)');
|
|
70
|
+
}
|
|
71
|
+
if (!manifest || typeof manifest !== 'object' || manifest.format !== INCREMENTAL_FORMAT) {
|
|
72
|
+
return _rejected(`cache format is not ${INCREMENTAL_FORMAT} (found ${manifest && manifest.format ? manifest.format : 'legacy'})`);
|
|
73
|
+
}
|
|
74
|
+
const listed = manifest.files && typeof manifest.files === 'object' ? manifest.files : null;
|
|
75
|
+
if (!listed || !manifest.version || typeof manifest.version !== 'object') return _rejected('cache manifest is incomplete');
|
|
76
|
+
const parsed = {};
|
|
77
|
+
for (const name of DATA_FILES) {
|
|
78
|
+
const entry = listed[name];
|
|
79
|
+
if (!entry || typeof entry.sha256 !== 'string') return _rejected(`cache manifest does not list ${name}`);
|
|
80
|
+
let text;
|
|
81
|
+
try { text = fs.readFileSync(path.join(dir, name), 'utf8'); }
|
|
82
|
+
catch { return _rejected(`incomplete cache: ${name} is missing`); }
|
|
83
|
+
const got = crypto.createHash('sha256').update(text).digest('hex');
|
|
84
|
+
if (got !== entry.sha256) return _rejected(`cache record ${name} failed its integrity digest (interrupted write or corruption)`);
|
|
85
|
+
try { parsed[name] = JSON.parse(text); }
|
|
86
|
+
catch { return _rejected(`cache record ${name} does not parse`); }
|
|
60
87
|
}
|
|
88
|
+
if (!parsed[FILES_PATH] || typeof parsed[FILES_PATH] !== 'object' || Array.isArray(parsed[FILES_PATH])) return _rejected('cache record files.json has the wrong shape');
|
|
89
|
+
if (!parsed[SUMMARIES_PATH] || typeof parsed[SUMMARIES_PATH] !== 'object') return _rejected('cache record summaries.json has the wrong shape');
|
|
90
|
+
if (parsed[DEPS_PATH] !== null && (typeof parsed[DEPS_PATH] !== 'object' || (parsed[DEPS_PATH] && parsed[DEPS_PATH].schema !== 'dep-graph/1'))) {
|
|
91
|
+
return _rejected('cache dependency graph has an incompatible schema');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
version: manifest.version,
|
|
95
|
+
generation: manifest.generation || null,
|
|
96
|
+
files: parsed[FILES_PATH],
|
|
97
|
+
summaries: parsed[SUMMARIES_PATH],
|
|
98
|
+
deps: parsed[DEPS_PATH],
|
|
99
|
+
replay: parsed[REPLAY_PATH],
|
|
100
|
+
};
|
|
61
101
|
}
|
|
62
102
|
|
|
63
103
|
function _emptyState() {
|
|
64
|
-
return { version: null, files: {}, summaries: {} };
|
|
104
|
+
return { version: null, files: {}, summaries: {}, deps: null, replay: null };
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
if (!fs.existsSync(fp)) return fallback;
|
|
70
|
-
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
71
|
-
} catch (_e) {
|
|
72
|
-
return fallback;
|
|
73
|
-
}
|
|
107
|
+
function _rejected(reason) {
|
|
108
|
+
return { ..._emptyState(), rejected: reason };
|
|
74
109
|
}
|
|
75
110
|
|
|
76
111
|
/**
|
|
@@ -78,6 +113,7 @@ function _readJsonOrEmpty(fp, fallback) {
|
|
|
78
113
|
* Returns `{ valid: boolean, reason?: string }`.
|
|
79
114
|
*/
|
|
80
115
|
export function validateIncrementalState(state, currentVersion) {
|
|
116
|
+
if (state && state.rejected) return { valid: false, reason: state.rejected };
|
|
81
117
|
if (!state || !state.version) return { valid: false, reason: 'no prior state' };
|
|
82
118
|
if (!currentVersion) return { valid: false, reason: 'no current version' };
|
|
83
119
|
if (state.version.scanner !== currentVersion.scanner) return { valid: false, reason: 'scanner version changed' };
|
|
@@ -163,7 +199,19 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
|
|
|
163
199
|
mutatedThisFields: new Set(s.mutatedThisFields || []),
|
|
164
200
|
taintedGlobals: new Set(s.taintedGlobals || []),
|
|
165
201
|
findings: Array.isArray(s.findings) ? s.findings : [],
|
|
202
|
+
// DAG-07 R07.3: provenance of a restored summary. Never serialized
|
|
203
|
+
// (serializeSummaries copies named fields only), so it cannot leak back
|
|
204
|
+
// into the persisted state; SummaryCache reads it to tell a restored
|
|
205
|
+
// empty-entry summary standing in for a tainted context from a computed one.
|
|
206
|
+
_incrementalSeed: true,
|
|
166
207
|
};
|
|
208
|
+
if (Number.isInteger(s.findingsTruncated) && s.findingsTruncated > 0) {
|
|
209
|
+
// Findings beyond the persist cap were dropped when this was saved, so
|
|
210
|
+
// reuse is a partial replay of what the function reported.
|
|
211
|
+
summary._findingsTruncated = s.findingsTruncated;
|
|
212
|
+
if (summaryCache._incrementalFindingsTruncated === undefined) summaryCache._incrementalFindingsTruncated = 0;
|
|
213
|
+
summaryCache._incrementalFindingsTruncated += 1;
|
|
214
|
+
}
|
|
167
215
|
// Use the bottom taint-state key — these are summaries that DON'T depend
|
|
168
216
|
// on entry taint state (e.g., pure functions). Higher-fidelity reuse
|
|
169
217
|
// would require persisting the entry-state hash too; deferred.
|
|
@@ -198,59 +246,213 @@ export function seedSummaryCache(summaryCache, persisted, reusableQids) {
|
|
|
198
246
|
export function serializeSummaries(summaryCache) {
|
|
199
247
|
const out = {};
|
|
200
248
|
if (!summaryCache || !summaryCache._cache) return out;
|
|
249
|
+
// DAG-07 R07.3. The cache key is `<qid>::<entryHash>[@caller][::receiver]`
|
|
250
|
+
// and a qid itself contains `::`, so the old `key.split('::')[0]` reduced
|
|
251
|
+
// every key to its FILE and let whichever entry context was written last
|
|
252
|
+
// stand for the whole file. Only the EMPTY-entry summary is a statement
|
|
253
|
+
// about what a function does when nothing tainted flows in; a summary
|
|
254
|
+
// computed under a tainted entry must never be restored as that. Parse the
|
|
255
|
+
// key from the right, persist the empty-entry summary under the real qid,
|
|
256
|
+
// and count what was not persisted so reuse can be reported honestly.
|
|
257
|
+
const EMPTY_SUFFIX = '::empty';
|
|
201
258
|
let count = 0;
|
|
259
|
+
const skippedContexts = new Map();
|
|
202
260
|
for (const [key, summary] of summaryCache._cache) {
|
|
203
|
-
if (count >= MAX_PERSISTED_SUMMARIES) break;
|
|
204
261
|
if (!summary || summary._budgetExceeded || summary._recursive) continue;
|
|
205
|
-
|
|
262
|
+
if (!key.endsWith(EMPTY_SUFFIX)) {
|
|
263
|
+
const m = /^(.*?::[^:]*@\d+)::/.exec(key);
|
|
264
|
+
const q = m ? m[1] : key;
|
|
265
|
+
skippedContexts.set(q, (skippedContexts.get(q) || 0) + 1);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const qid = key.slice(0, -EMPTY_SUFFIX.length);
|
|
206
269
|
if (!qid) continue;
|
|
270
|
+
if (count >= MAX_PERSISTED_SUMMARIES) break;
|
|
271
|
+
const findings = Array.isArray(summary.findings) ? summary.findings : [];
|
|
207
272
|
out[qid] = {
|
|
208
273
|
returnTainted: !!summary.returnTainted,
|
|
209
274
|
mutatedParams: [...(summary.mutatedParams || [])],
|
|
210
275
|
mutatedThisFields: [...(summary.mutatedThisFields || [])],
|
|
211
276
|
taintedGlobals: [...(summary.taintedGlobals || [])],
|
|
212
|
-
findings:
|
|
277
|
+
findings: findings.slice(0, 50),
|
|
278
|
+
entryContext: 'empty',
|
|
279
|
+
...(findings.length > 50 ? { findingsTruncated: findings.length - 50 } : {}),
|
|
213
280
|
};
|
|
214
281
|
count++;
|
|
215
282
|
}
|
|
283
|
+
for (const [q, n] of skippedContexts) if (out[q]) out[q].contextsNotPersisted = n;
|
|
216
284
|
return out;
|
|
217
285
|
}
|
|
218
286
|
|
|
287
|
+
// ── DAG-05 R05.3: typed, context-keyed persistence ──────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A copy of a finding that survives JSON. An expression-object `callee` is kept as a structural copy:
|
|
291
|
+
* it is plain data, equal by value to the original, and only the backward slicer compares it by
|
|
292
|
+
* reference (replay is refused under that option). Returns null when the value cannot be serialised
|
|
293
|
+
* at all (a cycle), which makes the owner non-replayable rather than lossy.
|
|
294
|
+
*/
|
|
295
|
+
export function jsonSafeFinding(f) {
|
|
296
|
+
try { return JSON.parse(JSON.stringify(f)); } catch { return null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const CONTEXT_SCHEMA = 'summary-context/1';
|
|
300
|
+
const MAX_FINDINGS_PER_CONTEXT = 200;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every computed context of every function, each under its COMPLETE key: qid,
|
|
304
|
+
* the canonical entry-taint list, the state hash derived from it and the
|
|
305
|
+
* receiver hash. A context is either persisted whole or counted in `skipped`
|
|
306
|
+
* with the reason; it is never reduced to the empty-entry slot.
|
|
307
|
+
*
|
|
308
|
+
* { schema, contexts: { qid: [ { entry:[...], stateHash, recv, summary } ] }, skipped: { reason: n } }
|
|
309
|
+
*/
|
|
310
|
+
export function serializeContexts(summaryCache) {
|
|
311
|
+
const contexts = {};
|
|
312
|
+
const skipped = {};
|
|
313
|
+
const note = (r) => { skipped[r] = (skipped[r] || 0) + 1; };
|
|
314
|
+
if (!summaryCache || !summaryCache._cache) return { schema: CONTEXT_SCHEMA, contexts, skipped };
|
|
315
|
+
const meta = summaryCache._meta;
|
|
316
|
+
let count = 0;
|
|
317
|
+
for (const [key, summary] of summaryCache._cache) {
|
|
318
|
+
if (!summary) continue;
|
|
319
|
+
if (summary._budgetExceeded) { note('budget-exceeded'); continue; }
|
|
320
|
+
if (summary._recursive) { note('recursion-stub'); continue; }
|
|
321
|
+
const m = meta && meta.get(key);
|
|
322
|
+
if (!m) { note('no-key-metadata'); continue; }
|
|
323
|
+
const findings = Array.isArray(summary.findings) ? summary.findings : [];
|
|
324
|
+
if (findings.length > MAX_FINDINGS_PER_CONTEXT) { note('findings-over-cap'); continue; }
|
|
325
|
+
const safe = [];
|
|
326
|
+
let ok = true;
|
|
327
|
+
for (const f of findings) { const c = jsonSafeFinding(f); if (c === null) { ok = false; break; } safe.push(c); }
|
|
328
|
+
if (!ok) { note('findings-not-serialisable'); continue; }
|
|
329
|
+
if (count >= MAX_PERSISTED_SUMMARIES) { note('persist-cap'); continue; }
|
|
330
|
+
(contexts[m.qid] ||= []).push({
|
|
331
|
+
entry: m.entry, stateHash: m.stateHash, recv: m.recv || null,
|
|
332
|
+
summary: {
|
|
333
|
+
returnTainted: !!summary.returnTainted,
|
|
334
|
+
mutatedParams: [...(summary.mutatedParams || [])].sort(),
|
|
335
|
+
mutatedThisFields: [...(summary.mutatedThisFields || [])].sort(),
|
|
336
|
+
taintedGlobals: [...(summary.taintedGlobals || [])].sort(),
|
|
337
|
+
findings: safe,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
count++;
|
|
341
|
+
}
|
|
342
|
+
for (const q of Object.keys(contexts)) contexts[q].sort((a, b) => (a.stateHash + (a.recv || '') < b.stateHash + (b.recv || '') ? -1 : 1));
|
|
343
|
+
return { schema: CONTEXT_SCHEMA, contexts, skipped };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Restore persisted contexts for the given function ids. Each record is checked
|
|
348
|
+
* (shape, entry list reproduces its state hash) before it is stored under its
|
|
349
|
+
* real key; a record that fails is counted and ignored, so the function simply
|
|
350
|
+
* recomputes that context. Returns { restored, rejected }.
|
|
351
|
+
*/
|
|
352
|
+
export function restoreContexts(summaryCache, persisted, qids) {
|
|
353
|
+
let restored = 0; let rejected = 0;
|
|
354
|
+
if (!summaryCache || !persisted || persisted.schema !== CONTEXT_SCHEMA || !persisted.contexts) return { restored, rejected };
|
|
355
|
+
for (const qid of qids) {
|
|
356
|
+
for (const rec of persisted.contexts[qid] || []) {
|
|
357
|
+
const s = rec && rec.summary;
|
|
358
|
+
if (!s || !Array.isArray(rec.entry) || typeof rec.stateHash !== 'string' || !Array.isArray(s.findings)) { rejected++; continue; }
|
|
359
|
+
const summary = {
|
|
360
|
+
returnTainted: !!s.returnTainted,
|
|
361
|
+
mutatedParams: new Set(s.mutatedParams || []),
|
|
362
|
+
mutatedThisFields: new Set(s.mutatedThisFields || []),
|
|
363
|
+
taintedGlobals: new Set(s.taintedGlobals || []),
|
|
364
|
+
findings: s.findings,
|
|
365
|
+
};
|
|
366
|
+
if (summaryCache.restoreContext(qid, rec.entry, rec.recv || null, rec.stateHash, summary)) restored++; else rejected++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { restored, rejected };
|
|
370
|
+
}
|
|
371
|
+
|
|
219
372
|
/**
|
|
220
|
-
* Commit incremental state to disk. Idempotent
|
|
373
|
+
* Commit incremental state to disk. Idempotent: safe to call from anywhere.
|
|
221
374
|
*
|
|
222
375
|
* state.files { filepath: sha256 }
|
|
223
376
|
* state.summaries { qid: summary } (output of serializeSummaries)
|
|
224
377
|
* state.callers { qid: [callerQid] } (reverse call-graph)
|
|
378
|
+
* state.deps DAG-05 dependency model + global components (or null)
|
|
379
|
+
* state.replay DAG-05 typed contexts / exit states / findings (or null)
|
|
225
380
|
* currentVersion { scanner, rules }
|
|
381
|
+
*
|
|
382
|
+
* DAG-05 R05.6: atomic. Every data file is written to a uniquely named temp file
|
|
383
|
+
* and fsynced, then renamed over its final name, and the manifest (version.json,
|
|
384
|
+
* which holds each file's SHA-256) is renamed LAST. A crash anywhere in that
|
|
385
|
+
* sequence leaves either the previous manifest with data it no longer matches, or
|
|
386
|
+
* a half-renamed set; `readIncrementalState` rejects both whole, so the next scan
|
|
387
|
+
* is cold, never a mixture of two generations. `io` is a seam for tests that
|
|
388
|
+
* simulate the crash (`io.renameSync`).
|
|
226
389
|
*/
|
|
227
|
-
export function commitIncrementalState(projectRoot, state, currentVersion) {
|
|
390
|
+
export function commitIncrementalState(projectRoot, state, currentVersion, io = {}) {
|
|
228
391
|
if (!projectRoot) return false;
|
|
229
392
|
const dir = statePath(projectRoot, 'incremental');
|
|
393
|
+
const rename = io.renameSync || fs.renameSync;
|
|
394
|
+
const gen = crypto.randomBytes(6).toString('hex');
|
|
395
|
+
const temps = [];
|
|
230
396
|
try {
|
|
231
|
-
if (!stateWritesEnabled()) return;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
397
|
+
if (!stateWritesEnabled()) return false;
|
|
398
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
399
|
+
const payloads = {
|
|
400
|
+
[FILES_PATH]: state.files || {},
|
|
401
|
+
[SUMMARIES_PATH]: { summaries: state.summaries || {}, callers: state.callers || {} },
|
|
402
|
+
[DEPS_PATH]: state.deps || null,
|
|
403
|
+
[REPLAY_PATH]: state.replay || null,
|
|
238
404
|
};
|
|
239
|
-
|
|
405
|
+
const listed = {};
|
|
406
|
+
for (const name of DATA_FILES) {
|
|
407
|
+
const text = JSON.stringify(payloads[name]);
|
|
408
|
+
listed[name] = { sha256: crypto.createHash('sha256').update(text).digest('hex'), bytes: Buffer.byteLength(text) };
|
|
409
|
+
const tmp = path.join(dir, `${name}.tmp-${gen}`);
|
|
410
|
+
temps.push(tmp);
|
|
411
|
+
_writeFileTemp(tmp, text);
|
|
412
|
+
}
|
|
413
|
+
const manifest = { format: INCREMENTAL_FORMAT, generation: gen, version: currentVersion, files: listed };
|
|
414
|
+
const manifestTmp = path.join(dir, `${VERSION_PATH}.tmp-${gen}`);
|
|
415
|
+
temps.push(manifestTmp);
|
|
416
|
+
_writeFileTemp(manifestTmp, JSON.stringify(manifest, null, 2));
|
|
417
|
+
for (const name of DATA_FILES) rename(path.join(dir, `${name}.tmp-${gen}`), path.join(dir, name));
|
|
418
|
+
rename(manifestTmp, path.join(dir, VERSION_PATH));
|
|
419
|
+
_sweepTemps(dir, gen);
|
|
240
420
|
return true;
|
|
241
421
|
} catch (_e) {
|
|
422
|
+
// Leave whatever the crash left: the reader rejects an inconsistent set. Only
|
|
423
|
+
// our own temp files are removed, and only the ones not yet renamed.
|
|
424
|
+
for (const t of temps) { try { if (fs.existsSync(t)) fs.unlinkSync(t); } catch { /* best effort */ } }
|
|
242
425
|
return false;
|
|
243
426
|
}
|
|
244
427
|
}
|
|
245
428
|
|
|
246
|
-
|
|
429
|
+
// No fsync, on purpose: the reader checks the SHA-256 of every file the manifest names, so a write
|
|
430
|
+
// that did not reach the disk (power loss) is detected and rejected whole and the next scan is cold.
|
|
431
|
+
// Durability is not what protects the cache; the digest check is. An fsync per file cost more than
|
|
432
|
+
// the analysis it saved on a small edit.
|
|
433
|
+
function _writeFileTemp(fp, text) {
|
|
434
|
+
fs.writeFileSync(fp, text, { mode: 0o600 });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Remove temp files left by an earlier interrupted commit (never the current one's). */
|
|
438
|
+
function _sweepTemps(dir, keepGen) {
|
|
439
|
+
try {
|
|
440
|
+
for (const n of fs.readdirSync(dir)) {
|
|
441
|
+
if (/\.tmp-[0-9a-f]+$/.test(n) && !n.endsWith(`.tmp-${keepGen}`)) { try { fs.unlinkSync(path.join(dir, n)); } catch { /* best effort */ } }
|
|
442
|
+
}
|
|
443
|
+
} catch { /* best effort */ }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Drop persisted state, used when a version mismatch is detected. */
|
|
247
447
|
export function dropIncrementalState(projectRoot) {
|
|
248
448
|
const dir = statePath(projectRoot, 'incremental');
|
|
249
449
|
try {
|
|
250
450
|
if (!fs.existsSync(dir)) return true;
|
|
251
|
-
for (const fn of
|
|
252
|
-
|
|
253
|
-
|
|
451
|
+
for (const fn of fs.readdirSync(dir)) {
|
|
452
|
+
if (DATA_FILES.includes(fn) || fn === VERSION_PATH || /\.tmp-[0-9a-f]+$/.test(fn)) {
|
|
453
|
+
const fp = path.join(dir, fn);
|
|
454
|
+
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
455
|
+
}
|
|
254
456
|
}
|
|
255
457
|
return true;
|
|
256
458
|
} catch (_e) {
|
package/src/dataflow/index.js
CHANGED
|
@@ -6,11 +6,9 @@ import { applyPathFeasibility, buildConstantFnMap } from './path-feasibility.js'
|
|
|
6
6
|
import { SummaryCache, entryStateFromCall } from './summaries.js';
|
|
7
7
|
import { rhsReachableFunctions, shouldAnalyzeUnderRhs } from './tabulation.js';
|
|
8
8
|
import { annotateBackwardSlices } from './backward.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
serializeSummaries, commitIncrementalState,
|
|
13
|
-
} from './incremental.js';
|
|
9
|
+
// DAG-05: dependency-complete incremental analysis (see incremental-session.js).
|
|
10
|
+
import { beginIncrementalSession, engineOptionsFor, finishIncrementalSession } from './incremental-session.js';
|
|
11
|
+
import { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
|
|
14
12
|
import { buildPointsTo } from './points-to.js';
|
|
15
13
|
import { buildClassHierarchy, annotateRTA } from '../ir/class-hierarchy.js';
|
|
16
14
|
import { annotateSoftTaint } from './soft-taint.js';
|
|
@@ -34,57 +32,15 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
34
32
|
// P2.1 — RHS-lite reachability slice. When AGENTIC_SECURITY_RHS=1 the
|
|
35
33
|
// engine narrows analysis to sink-reachable functions. Default OFF
|
|
36
34
|
// because it changes the finding-set composition.
|
|
35
|
+
let rhsCompletion = null;
|
|
37
36
|
if (process.env.AGENTIC_SECURITY_RHS === '1') {
|
|
38
37
|
const ctx = rhsReachableFunctions(perFileIR, callGraph);
|
|
38
|
+
rhsCompletion = ctx.completion || null;
|
|
39
39
|
if (ctx.reachable) {
|
|
40
40
|
opts = { ...opts, _rhsReachable: ctx.reachable, _rhsCheck: shouldAnalyzeUnderRhs };
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
// v0.69 — cross-scan incremental cache (AGENTIC_SECURITY_INCREMENTAL=1).
|
|
44
|
-
// Read persisted state, seed the SummaryCache with summaries from files
|
|
45
|
-
// whose content hasn't changed, then hand it to runTaintEngine. After,
|
|
46
|
-
// serialize the cache and commit to disk.
|
|
47
43
|
const incrementalEnabled = process.env.AGENTIC_SECURITY_INCREMENTAL === '1';
|
|
48
|
-
let preSeededCache = null;
|
|
49
|
-
let priorState = null;
|
|
50
|
-
let currentFileHashes = null;
|
|
51
|
-
if (incrementalEnabled && opts.scanRoot && opts.fileContents) {
|
|
52
|
-
priorState = readIncrementalState(opts.scanRoot);
|
|
53
|
-
const currentVersion = {
|
|
54
|
-
scanner: opts.scannerVersion || 'unknown',
|
|
55
|
-
rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
|
|
56
|
-
};
|
|
57
|
-
// Compute current file hashes UNCONDITIONALLY — the first scan (and any
|
|
58
|
-
// scan after a version/rule invalidation) must persist a baseline so the
|
|
59
|
-
// NEXT scan has something to reuse. This previously lived inside the
|
|
60
|
-
// valid-state branch, so a cold cache never committed and incremental was
|
|
61
|
-
// a permanent no-op (the commit guard at the bottom keys on currentFileHashes).
|
|
62
|
-
currentFileHashes = {};
|
|
63
|
-
for (const [fp, content] of Object.entries(opts.fileContents)) {
|
|
64
|
-
currentFileHashes[fp] = hashFileContent(content);
|
|
65
|
-
}
|
|
66
|
-
const valid = validateIncrementalState(priorState, currentVersion);
|
|
67
|
-
if (valid.valid) {
|
|
68
|
-
const diff = diffFileHashes(priorState.files || {}, currentFileHashes);
|
|
69
|
-
const changedQids = new Set();
|
|
70
|
-
// Map a changed file to the qids it owns. perFileIR exposes file→fns.
|
|
71
|
-
for (const fp of [...diff.changed, ...diff.added, ...diff.removed]) {
|
|
72
|
-
const ir = perFileIR[fp];
|
|
73
|
-
if (!ir) continue;
|
|
74
|
-
for (const fn of (ir.functions || [])) changedQids.add(fn.qid);
|
|
75
|
-
}
|
|
76
|
-
const persistedPayload = (priorState.summaries && priorState.summaries.summaries) || priorState.summaries || {};
|
|
77
|
-
const callerOfQid = (priorState.summaries && priorState.summaries.callers) || {};
|
|
78
|
-
const { reusable } = pickReusableSummaries(persistedPayload, callerOfQid, changedQids);
|
|
79
|
-
preSeededCache = new SummaryCache();
|
|
80
|
-
const seededN = seedSummaryCache(preSeededCache, persistedPayload, reusable);
|
|
81
|
-
preSeededCache._incrementalSeeded = seededN;
|
|
82
|
-
preSeededCache._incrementalReusable = reusable.size;
|
|
83
|
-
} else {
|
|
84
|
-
// Stale → caller should drop; we just don't seed.
|
|
85
|
-
priorState = null;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
44
|
// PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): Class Hierarchy
|
|
89
45
|
// Analysis. Built once per scan (mirrors the points-to graph immediately
|
|
90
46
|
// below) and threaded through opts so the taint engine can (a) narrow
|
|
@@ -119,12 +75,18 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
119
75
|
// Runs the formal Reps-Horwitz-Sagiv tabulation in parallel with the
|
|
120
76
|
// worklist engine. We MERGE findings — the IFDS solver may catch
|
|
121
77
|
// context-sensitive flows the k=2 cache joined out. Deduped by sink+line.
|
|
78
|
+
// DAG-05: the session decides warm or cold from content fingerprints (it needs the
|
|
79
|
+
// call graph and class hierarchy built above) and restores typed contexts.
|
|
80
|
+
const incrementalSession = beginIncrementalSession({ enabled: incrementalEnabled, opts, perFileIR, callGraph, cha: classHierarchy });
|
|
122
81
|
let findings = runTaintEngine(perFileIR, callGraph, {
|
|
123
82
|
...opts,
|
|
124
|
-
|
|
83
|
+
...engineOptionsFor(incrementalSession),
|
|
125
84
|
_pointsTo: pointsToGraph || undefined,
|
|
126
85
|
_cha: classHierarchy || undefined,
|
|
127
86
|
});
|
|
87
|
+
const engineOutput = findings;
|
|
88
|
+
// DAG-05: provenance + persistence happen right after the engine, on the engine's own array.
|
|
89
|
+
const incrementalStats = finishIncrementalSession(incrementalSession, engineOutput);
|
|
128
90
|
if (process.env.AGENTIC_SECURITY_IFDS === '1') {
|
|
129
91
|
try {
|
|
130
92
|
const ifdsFindings = runIfdsTaintEngine(perFileIR, callGraph, opts);
|
|
@@ -152,16 +114,20 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
152
114
|
}
|
|
153
115
|
} catch { /* privacy-deep failure should not fail the scan */ }
|
|
154
116
|
}
|
|
117
|
+
// DAG-02 begin (demand-driven sink queries; opt-in, additive, never changes `findings`)
|
|
118
|
+
if (demandEnabled() && Array.isArray(opts.demandQueries) && opts.demandQueries.length) {
|
|
119
|
+
try {
|
|
120
|
+
const demandResults = runDemandQueries(perFileIR, callGraph, opts.demandQueries, { ...(opts.demandOpts || {}), engineOpts: { ...opts, _cha: classHierarchy || undefined, _pointsTo: pointsToGraph || undefined } });
|
|
121
|
+
Object.defineProperty(findings, '_demandResults', { value: demandResults, enumerable: false });
|
|
122
|
+
} catch { /* demand queries must never fail the scan */ }
|
|
123
|
+
}
|
|
124
|
+
// DAG-02 end
|
|
155
125
|
for (const f of findings) f._pathFeasibilityPruned = totalPruned;
|
|
156
|
-
if (
|
|
157
|
-
Object.defineProperty(findings, '_incrementalStats', {
|
|
158
|
-
|
|
159
|
-
seeded: preSeededCache._incrementalSeeded || 0,
|
|
160
|
-
reusable: preSeededCache._incrementalReusable || 0,
|
|
161
|
-
},
|
|
162
|
-
enumerable: false,
|
|
163
|
-
});
|
|
126
|
+
if (incrementalStats) {
|
|
127
|
+
Object.defineProperty(findings, '_incrementalStats', { value: incrementalStats, enumerable: false });
|
|
128
|
+
if (findings !== engineOutput) Object.defineProperty(engineOutput, '_incrementalStats', { value: incrementalStats, enumerable: false });
|
|
164
129
|
}
|
|
130
|
+
if (rhsCompletion) Object.defineProperty(findings, '_rhsCompletion', { value: rhsCompletion, enumerable: false });
|
|
165
131
|
// P1.4 — backward slice (opt-in via AGENTIC_SECURITY_BACKWARD_SLICE=1).
|
|
166
132
|
// Next-gen taint capability #5 (SMT path-feasibility rebuild): ALSO run
|
|
167
133
|
// this when AGENTIC_SECURITY_SYMEXEC=1 is set, even if the operator never
|
|
@@ -221,43 +187,8 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
221
187
|
}
|
|
222
188
|
} catch { /* prover failure should not fail the scan */ }
|
|
223
189
|
}
|
|
224
|
-
// v0.69 — commit incremental state after a successful scan.
|
|
225
|
-
if (incrementalEnabled && opts.scanRoot && currentFileHashes) {
|
|
226
|
-
const cache = findings._summaryCache;
|
|
227
|
-
const summaries = cache ? serializeSummaries(cache) : {};
|
|
228
|
-
// Reverse call-graph (qid → callers) — derive from callGraph.
|
|
229
|
-
const callers = {};
|
|
230
|
-
if (callGraph && callGraph.functions) {
|
|
231
|
-
for (const fn of callGraph.functions.values()) {
|
|
232
|
-
if (!Array.isArray(fn.calls)) continue;
|
|
233
|
-
for (const c of fn.calls) {
|
|
234
|
-
// fn.calls entries are objects ({site, callee, args, line}); using one
|
|
235
|
-
// as an object key stringifies it to "[object Object]" and collapses
|
|
236
|
-
// every entry into a single bogus bucket. Key by the RESOLVED qid so
|
|
237
|
-
// this map is queryable by the qids the worklist actually uses.
|
|
238
|
-
const name = c && typeof c === 'object' ? c.callee : c;
|
|
239
|
-
if (!name) continue;
|
|
240
|
-
// resolveKnownCallee (not resolve): a raw fn.calls name may be a
|
|
241
|
-
// flattened member-call string ("loader.read"); the plain
|
|
242
|
-
// resolve() would guess via its bare-tail fallback and invent an
|
|
243
|
-
// edge to an unrelated same-named function. See callgraph.js.
|
|
244
|
-
const qid = callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, fn.file) : null;
|
|
245
|
-
if (!qid) continue;
|
|
246
|
-
if (!callers[qid]) callers[qid] = [];
|
|
247
|
-
callers[qid].push(fn.qid);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
commitIncrementalState(opts.scanRoot, {
|
|
252
|
-
files: currentFileHashes,
|
|
253
|
-
summaries,
|
|
254
|
-
callers,
|
|
255
|
-
}, {
|
|
256
|
-
scanner: opts.scannerVersion || 'unknown',
|
|
257
|
-
rules: opts.rulesDigest || `catalog:${_catalogSize()}`,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
190
|
return findings;
|
|
261
191
|
}
|
|
262
192
|
|
|
193
|
+
export { demandEnabled, runDemandQueries } from './demand-query.js'; // DAG-02
|
|
263
194
|
export { runTaintEngine, CATALOG, matchSource, matchSinkOrSanitizer, _catalogSize, applyPathFeasibility, buildConstantFnMap, SummaryCache, entryStateFromCall, rhsReachableFunctions, shouldAnalyzeUnderRhs, annotateBackwardSlices };
|
|
@@ -52,12 +52,22 @@ function _hashState(taintedParams) {
|
|
|
52
52
|
return crypto.createHash('sha256').update(sorted).digest('hex').slice(0, 12);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// DAG-05 R05.3: the canonical entry list a key was derived from, so a persisted
|
|
56
|
+
// context can carry its COMPLETE key (qid, entry state, receiver) and be checked
|
|
57
|
+
// on reload rather than trusted by hash alone.
|
|
58
|
+
function entryListOf(taintedParams) {
|
|
59
|
+
if (!taintedParams || taintedParams.size === 0) return [];
|
|
60
|
+
return [...canonicalizeAccessSet(taintedParams)].sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export class SummaryCache {
|
|
56
64
|
constructor() {
|
|
57
65
|
this._cache = new Map(); // qid::hash → summary
|
|
58
66
|
this._stack = new Set(); // qids currently being analyzed (recursion guard)
|
|
59
67
|
this._iter = 0;
|
|
60
|
-
|
|
68
|
+
// Computation budget for one scan. AGENTIC_SECURITY_SUMMARY_BUDGET raises (or lowers) it; the default is unchanged.
|
|
69
|
+
const envBudget = Number(process.env.AGENTIC_SECURITY_SUMMARY_BUDGET);
|
|
70
|
+
this._maxIter = Number.isFinite(envBudget) && envBudget > 0 ? Math.floor(envBudget) : 5000;
|
|
61
71
|
// FR-SEM-2 (roadmap #2): the engine computes a distinct summary per
|
|
62
72
|
// entry-taint-state (value-context sensitivity), computed lazily at call
|
|
63
73
|
// sites. To stop that from blowing up when a hot helper is reached under
|
|
@@ -70,6 +80,13 @@ export class SummaryCache {
|
|
|
70
80
|
const envCap = Number(process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS);
|
|
71
81
|
this._maxContextsPerFn = Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
|
|
72
82
|
this._contextCapHits = 0;
|
|
83
|
+
// DAG-07 R07.3: append-only record of every place a result is coarser
|
|
84
|
+
// than a fresh analysis would give: a tainted entry answered with the
|
|
85
|
+
// empty-entry summary (context cap), a recursion refinement that was still
|
|
86
|
+
// changing when its rounds ran out, and the subset of cap fallbacks whose
|
|
87
|
+
// empty-entry summary was restored from a previous scan rather than
|
|
88
|
+
// computed in this one.
|
|
89
|
+
this._notes = [];
|
|
73
90
|
// R2 (PRD §5): bounded k=1 call-string sensitivity. OPT-IN (default OFF).
|
|
74
91
|
// When on, the cache key is extended with the immediate caller's qid, so two
|
|
75
92
|
// call paths reaching a helper with the SAME tainted-arg shape get DISTINCT
|
|
@@ -78,6 +95,9 @@ export class SummaryCache {
|
|
|
78
95
|
// before, so no behavior change for the shipped engine.
|
|
79
96
|
this._callString = process.env.AGENTIC_SECURITY_KCFA_CALLSTRING === '1';
|
|
80
97
|
this._callerCtx = null;
|
|
98
|
+
// DAG-05 R05.3: key -> { qid, entry:[...], stateHash, recv } for every key this
|
|
99
|
+
// cache has produced, so cross-scan persistence can store typed contexts.
|
|
100
|
+
this._meta = new Map();
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
// Set the immediate-caller qid for call-string keying; returns the previous
|
|
@@ -111,8 +131,38 @@ export class SummaryCache {
|
|
|
111
131
|
// caller dimension there only ever narrows an already-isolated lookup,
|
|
112
132
|
// never disconnects a shared one.
|
|
113
133
|
if (this._callString && this._callerCtx && stateHash !== 'empty') base = `${base}@${this._callerCtx}`;
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
const finalKey = receiverType ? `${base}::${hashReceiverType(receiverType)}` : base;
|
|
135
|
+
if (!this._meta.has(finalKey)) {
|
|
136
|
+
this._meta.set(finalKey, {
|
|
137
|
+
qid, entry: entryListOf(taintedParams), stateHash,
|
|
138
|
+
recv: receiverType ? hashReceiverType(receiverType) : null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return finalKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* DAG-05 R05.3: restore a persisted context under its REAL key. Never writes
|
|
146
|
+
* the empty-entry slot on behalf of a tainted context: the key is rebuilt from
|
|
147
|
+
* the persisted entry list and refused unless it reproduces the persisted hash.
|
|
148
|
+
* Returns false (nothing stored) on any mismatch.
|
|
149
|
+
*/
|
|
150
|
+
restoreContext(qid, entryList, recv, stateHash, summary) {
|
|
151
|
+
if (!qid || !Array.isArray(entryList) || !summary) return false;
|
|
152
|
+
const entry = new Set(entryList);
|
|
153
|
+
const h = _hashState(entry);
|
|
154
|
+
if (h !== stateHash) return false;
|
|
155
|
+
if (this._callString && h !== 'empty') return false;
|
|
156
|
+
const base = `${qid}::${h}`;
|
|
157
|
+
const key = recv ? `${base}::${recv}` : base;
|
|
158
|
+
this._cache.set(key, summary);
|
|
159
|
+
this._meta.set(key, { qid, entry: entryListOf(entry), stateHash: h, recv: recv || null });
|
|
160
|
+
if (h !== 'empty') {
|
|
161
|
+
let ctxs = this._contextsByQid.get(qid);
|
|
162
|
+
if (!ctxs) { ctxs = new Set(); this._contextsByQid.set(qid, ctxs); }
|
|
163
|
+
ctxs.add(h);
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
get(qid, taintedParams, receiverType) {
|
|
@@ -156,7 +206,11 @@ export class SummaryCache {
|
|
|
156
206
|
if (!ctxs.has(stateHash) && ctxs.size >= this._maxContextsPerFn) {
|
|
157
207
|
this._contextCapHits++;
|
|
158
208
|
const base = this._cache.get(this._key(qid, new Set()));
|
|
159
|
-
if (base && !base._recursive)
|
|
209
|
+
if (base && !base._recursive) {
|
|
210
|
+
this._notes.push({ reason: 'context-cap-fallback', qid });
|
|
211
|
+
if (base._incrementalSeed) this._notes.push({ reason: 'incremental-empty-entry-reuse', qid });
|
|
212
|
+
return base;
|
|
213
|
+
}
|
|
160
214
|
// No base summary yet → fall through and compute this one (we need a
|
|
161
215
|
// result), but don't grow the context set.
|
|
162
216
|
} else {
|
|
@@ -178,13 +232,15 @@ export class SummaryCache {
|
|
|
178
232
|
this._cache.set(k, summary);
|
|
179
233
|
if (this._hitRecursion) {
|
|
180
234
|
const FP_MAX = 3;
|
|
235
|
+
let converged = false;
|
|
181
236
|
for (let fp = 0; fp < FP_MAX; fp++) {
|
|
182
237
|
if (++this._iter > this._maxIter) break;
|
|
183
238
|
const prev = summary;
|
|
184
239
|
summary = analyze(qid, taintedParams);
|
|
185
|
-
if (_summaryEq(prev, summary)) break;
|
|
240
|
+
if (_summaryEq(prev, summary)) { converged = true; break; }
|
|
186
241
|
this._cache.set(k, summary);
|
|
187
242
|
}
|
|
243
|
+
if (!converged) this._notes.push({ reason: 'recursion-nonconverged', qid });
|
|
188
244
|
}
|
|
189
245
|
if (summary._recursive) delete summary._recursive;
|
|
190
246
|
return summary;
|
|
@@ -224,7 +280,17 @@ export class SummaryCache {
|
|
|
224
280
|
}
|
|
225
281
|
|
|
226
282
|
size() { return this._cache.size; }
|
|
227
|
-
clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; }
|
|
283
|
+
clear() { this._cache.clear(); this._iter = 0; this._contextsByQid.clear(); this._contextCapHits = 0; this._notes = []; }
|
|
284
|
+
|
|
285
|
+
/** DAG-07 R07.3: [{reason, count, example}] over everything recorded in `_notes`, sorted by reason. */
|
|
286
|
+
completionNotes() {
|
|
287
|
+
const byReason = new Map();
|
|
288
|
+
for (const n of this._notes) {
|
|
289
|
+
const cur = byReason.get(n.reason);
|
|
290
|
+
if (cur) cur.count += 1; else byReason.set(n.reason, { reason: n.reason, count: 1, example: String(n.qid ?? '').slice(0, 160) });
|
|
291
|
+
}
|
|
292
|
+
return [...byReason.values()].sort((a, b) => (a.reason < b.reason ? -1 : 1));
|
|
293
|
+
}
|
|
228
294
|
}
|
|
229
295
|
|
|
230
296
|
// Stage 3 correctness audit (detection depth): this compared mutatedParams
|