@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -0,0 +1,262 @@
1
+ // Addition #3 — Root-cause sweep with total-count accounting.
2
+ //
3
+ // A detector fires on the instance it can prove. But the same root cause is
4
+ // usually copy-pasted across the codebase, and most of those siblings never
5
+ // trip a rule (different variable names, an assignment wrapper, a file the
6
+ // scanner didn't reach with taint). This module takes CONFIRMED findings and
7
+ // sweeps every source line for structural siblings of the same sink, then
8
+ // accounts for every match honestly:
9
+ //
10
+ // found === candidates + mitigated (per sweep, always)
11
+ //
12
+ // where `found` is every structural match across the repo EXCLUDING the
13
+ // finding's own origin site, `mitigated` is the subset a detector already
14
+ // covered (a finding exists at that file:line), and `candidates` is the
15
+ // remainder — new instances nobody has looked at yet. Nothing is dropped.
16
+ //
17
+ // Matching reuses semantic-clone's normalized token-shape hashing (`shapeHash`)
18
+ // so that `db.query(a)` and `db.query(b)` collapse to one shape. Pure shape is
19
+ // too loose on its own (`db.query(x)` and `console.log(x)` both normalize to
20
+ // `ID.ID(ID)`), so we anchor on the LITERAL callee (`db.query`) and use the
21
+ // shape only to confirm the argument arity/structure. Anchor + shape = precise.
22
+ //
23
+ // Like semantic-clone this is a coarse structural approximation, not a proof of
24
+ // semantic equivalence. It catches the common "same call, cloned around" case.
25
+
26
+ import { shapeHash } from './semantic-clone.js';
27
+
28
+ // shapeHash defaults to minTokens:8 (tuned to avoid trivial clone collisions on
29
+ // whole functions). A single call expression is short — `foo(a)` is 4 tokens,
30
+ // `db.query(a)` is 6 — so we lower the floor for call-granular matching.
31
+ const MIN_SHAPE_TOKENS = 3;
32
+
33
+ // A callee whose final segment is one of these is control flow, not a sink call.
34
+ const CONTROL_KEYWORDS = new Set([
35
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'with', 'do', 'await',
36
+ ]);
37
+
38
+ function escapeRegex(s) {
39
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+
42
+ // Accept both a Map and a plain { path: source } object.
43
+ function toMap(fileContents) {
44
+ if (fileContents instanceof Map) return fileContents;
45
+ const m = new Map();
46
+ if (fileContents && typeof fileContents === 'object') {
47
+ for (const k of Object.keys(fileContents)) m.set(k, fileContents[k]);
48
+ }
49
+ return m;
50
+ }
51
+
52
+ // A finding qualifies for a sweep when it is confirmed. With confirmedOnly
53
+ // disabled we sweep everything (the caller has opted out of the gate).
54
+ function qualifies(finding, confirmedOnly) {
55
+ if (!confirmedOnly) return true;
56
+ return finding.confirmed === true || finding.confidenceTier === 'high';
57
+ }
58
+
59
+ // The origin site is the finding's own location; siblings must exclude it.
60
+ function originSite(finding) {
61
+ if (finding.sink && finding.sink.file && finding.sink.line != null) {
62
+ return { file: finding.sink.file, line: finding.sink.line };
63
+ }
64
+ return { file: finding.file ?? null, line: finding.line ?? null };
65
+ }
66
+
67
+ // Every file:line that already carries a finding — used to classify a match as
68
+ // 'mitigated-or-known' vs. a fresh 'candidate'.
69
+ function buildKnownLocations(findings) {
70
+ const set = new Set();
71
+ for (const f of Array.isArray(findings) ? findings : []) {
72
+ if (!f || typeof f !== 'object') continue;
73
+ if (f.file != null && f.line != null) set.add(`${f.file}:${f.line}`);
74
+ if (f.sink && f.sink.file != null && f.sink.line != null) set.add(`${f.sink.file}:${f.sink.line}`);
75
+ }
76
+ return set;
77
+ }
78
+
79
+ // Pull the leading callee path out of a call snippet: `db.query(x)` → `db.query`.
80
+ function extractCallee(snippet) {
81
+ if (!snippet || typeof snippet !== 'string') return null;
82
+ const m = snippet.match(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/);
83
+ if (!m) return null;
84
+ const callee = m[1];
85
+ const last = callee.split('.').pop();
86
+ if (CONTROL_KEYWORDS.has(last)) return null;
87
+ return callee;
88
+ }
89
+
90
+ // Extract the full balanced call expression for `callee` from `text`:
91
+ // `const r = db.query(f(1), g);` → `db.query(f(1), g)`. Null if absent/unbalanced.
92
+ function extractCall(text, callee) {
93
+ if (!text || typeof text !== 'string') return null;
94
+ const re = new RegExp('(?:^|[^\\w$.])' + escapeRegex(callee) + '\\s*\\(');
95
+ const m = re.exec(text);
96
+ if (!m) return null;
97
+ const calleeStart = m.index + m[0].indexOf(callee); // callee offset within the match
98
+
99
+ const open = text.indexOf('(', calleeStart);
100
+ if (open < 0) return null;
101
+ let depth = 0;
102
+ for (let i = open; i < text.length; i++) {
103
+ const ch = text[i];
104
+ if (ch === '(') depth++;
105
+ else if (ch === ')') {
106
+ depth--;
107
+ if (depth === 0) return text.slice(calleeStart, i + 1);
108
+ }
109
+ }
110
+ return null; // unbalanced on this line
111
+ }
112
+
113
+ // Structural shape of a sink snippet: hash of the normalized call expression
114
+ // (callee + args reduced to token kinds), reusing semantic-clone's hasher.
115
+ function sinkShapeOf(snippet) {
116
+ if (!snippet || typeof snippet !== 'string') return null;
117
+ const callee = extractCallee(snippet);
118
+ const call = callee ? (extractCall(snippet, callee) || snippet) : snippet;
119
+ return shapeHash(call, { minTokens: MIN_SHAPE_TOKENS });
120
+ }
121
+
122
+ // Build a searchable pattern from a finding's sink (preferred) or fall back to
123
+ // vuln/cwe keywords when no snippet is available.
124
+ function deriveSinkPattern(finding) {
125
+ const snippet = finding?.sink?.snippet || finding?.snippet || '';
126
+ const callee = extractCallee(snippet);
127
+ if (callee) {
128
+ return {
129
+ kind: 'call',
130
+ callee,
131
+ shape: sinkShapeOf(snippet),
132
+ regex: new RegExp('(?:^|[^\\w$.])' + escapeRegex(callee) + '\\s*\\('),
133
+ display: `${callee}(…)`,
134
+ };
135
+ }
136
+ const kw = keywordFor(finding);
137
+ if (kw) {
138
+ return { kind: 'keyword', keyword: kw, shape: null, regex: new RegExp(escapeRegex(kw), 'i'), display: kw };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ // Source pattern is reported for context; the sweep itself is sink-driven.
144
+ function deriveSourcePattern(finding) {
145
+ const s = finding?.source?.snippet;
146
+ if (s && typeof s === 'string' && s.trim()) return { display: s.trim() };
147
+ const kw = keywordFor(finding);
148
+ if (kw) return { display: kw };
149
+ return null;
150
+ }
151
+
152
+ function keywordFor(finding) {
153
+ const v = (finding?.vuln ?? '').toString().trim();
154
+ if (v) return v;
155
+ const cwe = (finding?.cwe ?? '').toString().trim();
156
+ if (cwe) return cwe;
157
+ return null;
158
+ }
159
+
160
+ // Does a single source line structurally match the sink pattern?
161
+ function matchLine(pattern, line) {
162
+ if (!pattern || typeof line !== 'string') return false;
163
+ if (pattern.kind === 'call') {
164
+ if (!pattern.regex.test(line)) return false; // literal callee anchor
165
+ if (pattern.shape == null) return true; // anchor-only (snippet too short to shape)
166
+ const call = extractCall(line, pattern.callee);
167
+ if (!call) return false;
168
+ return shapeHash(call, { minTokens: MIN_SHAPE_TOKENS }) === pattern.shape;
169
+ }
170
+ if (pattern.kind === 'keyword') {
171
+ return pattern.regex.test(line);
172
+ }
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Sweep confirmed findings for sibling instances of the same root cause.
178
+ *
179
+ * @param {Array<object>} findings scan findings (confirmed ones drive sweeps)
180
+ * @param {Map|object} fileContents { path: source } — Map or plain object
181
+ * @param {object} opts { confirmedOnly = true }
182
+ * @returns {{ sweeps: Array<object>, totals: {found,candidates,mitigated} }}
183
+ */
184
+ export function sweepRootCauses(findings, fileContents, opts = {}) {
185
+ const confirmedOnly = opts?.confirmedOnly !== false;
186
+ const list = Array.isArray(findings) ? findings : [];
187
+ const files = toMap(fileContents);
188
+ const knownLocations = buildKnownLocations(list);
189
+
190
+ const sweeps = [];
191
+ const totals = { found: 0, candidates: 0, mitigated: 0 };
192
+
193
+ for (const finding of list) {
194
+ if (!finding || typeof finding !== 'object') continue;
195
+ if (!qualifies(finding, confirmedOnly)) continue;
196
+
197
+ const sinkPattern = deriveSinkPattern(finding);
198
+ if (!sinkPattern) continue; // nothing searchable — skip rather than fabricate
199
+ const sourcePattern = deriveSourcePattern(finding);
200
+ const origin = originSite(finding);
201
+
202
+ const instances = [];
203
+ for (const [path, source] of files) {
204
+ if (source == null) continue;
205
+ const lines = String(source).split(/\r?\n/);
206
+ for (let i = 0; i < lines.length; i++) {
207
+ const line = lines[i];
208
+ if (!matchLine(sinkPattern, line)) continue;
209
+ const lineNo = i + 1;
210
+ if (path === origin.file && lineNo === origin.line) continue; // exclude the finding's own site
211
+ const status = knownLocations.has(`${path}:${lineNo}`) ? 'mitigated-or-known' : 'candidate';
212
+ instances.push({ file: path, line: lineNo, snippet: line.trim(), status });
213
+ }
214
+ }
215
+
216
+ const candidates = instances.filter((x) => x.status === 'candidate').length;
217
+ const mitigated = instances.filter((x) => x.status === 'mitigated-or-known').length;
218
+ const found = instances.length; // every match is exactly one status → invariant holds by construction
219
+
220
+ sweeps.push({
221
+ fromFindingId: finding.id ?? finding.stableId ?? null,
222
+ sourcePattern: sourcePattern ? sourcePattern.display : null,
223
+ sinkPattern: sinkPattern.display,
224
+ found,
225
+ candidates,
226
+ mitigated,
227
+ remaining: candidates, // unaccounted instances that still need triage
228
+ instances,
229
+ });
230
+
231
+ totals.found += found;
232
+ totals.candidates += candidates;
233
+ totals.mitigated += mitigated;
234
+ }
235
+
236
+ return { sweeps, totals };
237
+ }
238
+
239
+ /**
240
+ * One short human line per sweep, e.g.:
241
+ * "root-cause sweep: 20 found, 3 candidate, 17 mitigated"
242
+ */
243
+ export function formatSweepLedger(result) {
244
+ if (!result || !Array.isArray(result.sweeps)) return '';
245
+ return result.sweeps
246
+ .map((s) => `root-cause sweep: ${s.found} found, ${s.candidates} candidate, ${s.mitigated} mitigated`)
247
+ .join('\n');
248
+ }
249
+
250
+ export const _internals = {
251
+ MIN_SHAPE_TOKENS,
252
+ sinkShapeOf,
253
+ deriveSinkPattern,
254
+ deriveSourcePattern,
255
+ extractCallee,
256
+ extractCall,
257
+ matchLine,
258
+ qualifies,
259
+ originSite,
260
+ buildKnownLocations,
261
+ toMap,
262
+ };
@@ -9,7 +9,7 @@
9
9
 
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
- import * as yaml from 'js-yaml';
12
+ import * as yaml from '../util/yaml.js';
13
13
  import { verifyLastScan } from './integrity.js';
14
14
  import { statePath } from './state-dir.js';
15
15
 
@@ -38,7 +38,7 @@
38
38
 
39
39
  import * as fs from 'node:fs';
40
40
  import * as path from 'node:path';
41
- import * as yaml from 'js-yaml';
41
+ import * as yaml from '../util/yaml.js';
42
42
 
43
43
  const DEFAULT_POLICY = {
44
44
  acceptRisk: [],
@@ -0,0 +1,277 @@
1
+ // Scan checkpointing / resume (roadmap R8).
2
+ //
3
+ // Long scans currently restart from zero if interrupted, which is what caps the
4
+ // repository size this engine can usefully handle. This module lets the per-file
5
+ // loop in `engine.js#runFullScan` durably record what it has already analysed so
6
+ // a second invocation replays that work instead of redoing it.
7
+ //
8
+ // THE PROPERTY THAT MATTERS: a resumed scan must produce the same finding set as
9
+ // an uninterrupted one. A checkpoint that silently drops findings converts a slow
10
+ // scan into a quietly incomplete one, which is strictly worse than no checkpoint
11
+ // at all. Three design decisions follow from that and should not be relaxed:
12
+ //
13
+ // 1. We persist the *complete* per-file contribution, not just findings —
14
+ // routes, taint sources/sinks/sanitizers, logic vulns, secrets, ciphers,
15
+ // the per-file result the cross-file taint pass reads, and the suppression
16
+ // log delta. Anything the per-file loop appends to must round-trip, or the
17
+ // post-loop cross-file passes would see a different world on resume.
18
+ // 2. Only the per-file loop is checkpointed. Every cross-file pass and the
19
+ // whole annotation pipeline re-runs from scratch on resume, so nothing that
20
+ // depends on the global picture can be stale by construction.
21
+ // 3. Invalidation is conservative to the point of being blunt. The run key
22
+ // covers the engine version, the ruleset version, the bundle SHA, a content
23
+ // hash of every file in the scan (which subsumes mtime), and the scanner's
24
+ // own environment switches. If any of it moved, the checkpoint is discarded
25
+ // and the scan starts clean. Redoing work is merely slow; resuming stale
26
+ // work is a correctness bug.
27
+ //
28
+ // CRASH SAFETY: append-and-fsync. The file is a JSONL log — one header line
29
+ // pinning the run key, then one self-describing record per completed file,
30
+ // each carrying a SHA-256 of its own payload. Every record is written with a
31
+ // single `writeSync` and immediately `fsyncSync`'d before the next file is
32
+ // analysed, so a process killed at any instant leaves either a complete record
33
+ // or a torn tail. On recovery we read forward while records verify and truncate
34
+ // the file at the last byte offset that did, so a torn tail is discarded rather
35
+ // than resumed into. Nothing is ever rewritten in place, so there is no window
36
+ // in which the file is neither the old state nor the new one.
37
+ //
38
+ // Everything here follows the posture convention of never throwing: a failure to
39
+ // open, read or append degrades to "no checkpoint", which just means a full scan.
40
+
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ import * as crypto from 'node:crypto';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const STATE_DIR = '.agentic-security';
47
+ const FILE_NAME = 'scan-checkpoint.jsonl';
48
+ const FORMAT = 'agentic-security-scan-checkpoint/1';
49
+
50
+ // Env switches that change what the engine emits are part of the run identity.
51
+ // These three are deliberately excluded: they change how the run is driven, not
52
+ // what it would find.
53
+ const RUN_KEY_ENV_EXCLUDE = new Set([
54
+ 'AGENTIC_SECURITY_RESUME',
55
+ 'AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER',
56
+ 'AGENTIC_SECURITY_HMAC_KEY',
57
+ ]);
58
+
59
+ export function checkpointPath(scanRoot) {
60
+ return path.join(scanRoot || '.', STATE_DIR, FILE_NAME);
61
+ }
62
+
63
+ function _sha(s) {
64
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
65
+ }
66
+
67
+ // SHA-256 of the running bundle, taken from the sidecar next to it. Returns
68
+ // 'unavailable' when running from source — same convention as the attestation
69
+ // path, and deliberately not a guess at some other bundle's hash.
70
+ export function bundleShaForRunKey() {
71
+ try {
72
+ const here = path.dirname(fileURLToPath(import.meta.url));
73
+ // src/posture/ -> src/ -> scanner/
74
+ const sidecar = path.resolve(here, '..', '..', 'dist', 'agentic-security.mjs.sha256');
75
+ const raw = fs.readFileSync(sidecar, 'utf8').trim();
76
+ const m = /^([0-9a-f]{64})\b/.exec(raw);
77
+ if (m) return m[1];
78
+ } catch { /* not running from a built tree */ }
79
+ return 'unavailable';
80
+ }
81
+
82
+ /**
83
+ * Everything that would invalidate previously-completed per-file work, reduced
84
+ * to one hex digest. Content hashes rather than mtimes: strictly stronger, and
85
+ * immune to filesystems with coarse or non-monotonic timestamps.
86
+ */
87
+ export function computeRunKey({
88
+ engineVersion, rulesetVersion, bundleSha,
89
+ fileContents = {}, depFileContents = {}, env = process.env,
90
+ } = {}) {
91
+ const h = crypto.createHash('sha256');
92
+ h.update(FORMAT); h.update('\n');
93
+ h.update(String(engineVersion ?? '')); h.update('\n');
94
+ h.update(String(rulesetVersion ?? '')); h.update('\n');
95
+ h.update(String(bundleSha ?? 'unavailable')); h.update('\n');
96
+ for (const [label, map] of [['f', fileContents], ['d', depFileContents]]) {
97
+ const names = Object.keys(map || {}).sort();
98
+ h.update(label); h.update(String(names.length)); h.update('\n');
99
+ for (const n of names) {
100
+ h.update(n); h.update('\0');
101
+ h.update(_sha(String(map[n] ?? '')));
102
+ h.update('\n');
103
+ }
104
+ }
105
+ const envKeys = Object.keys(env || {})
106
+ .filter(k => k.startsWith('AGENTIC_SECURITY_') && !RUN_KEY_ENV_EXCLUDE.has(k))
107
+ .sort();
108
+ h.update('e'); h.update(String(envKeys.length)); h.update('\n');
109
+ for (const k of envKeys) { h.update(k); h.update('='); h.update(String(env[k])); h.update('\n'); }
110
+ return h.digest('hex');
111
+ }
112
+
113
+ // A value is safe to checkpoint only if JSON can carry it back unchanged. Dates,
114
+ // regexes, Maps, Sets, functions and BigInts all survive `JSON.stringify` in a
115
+ // lossy or throwing way; recording one would mean the resumed run sees different
116
+ // data than the uninterrupted run did. We refuse the record instead, and the
117
+ // file just gets rescanned.
118
+ function _jsonSafe(v, depth = 0, seen = new Set()) {
119
+ if (depth > 24) return false;
120
+ if (v === null || v === undefined) return true;
121
+ const t = typeof v;
122
+ if (t === 'string' || t === 'boolean') return true;
123
+ if (t === 'number') return Number.isFinite(v);
124
+ if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
125
+ if (t !== 'object') return false;
126
+ if (seen.has(v)) return false;
127
+ seen.add(v);
128
+ try {
129
+ if (Array.isArray(v)) {
130
+ for (const x of v) if (!_jsonSafe(x, depth + 1, seen)) return false;
131
+ return true;
132
+ }
133
+ const proto = Object.getPrototypeOf(v);
134
+ if (proto !== Object.prototype && proto !== null) return false;
135
+ for (const k of Object.keys(v)) if (!_jsonSafe(v[k], depth + 1, seen)) return false;
136
+ return true;
137
+ } finally {
138
+ seen.delete(v);
139
+ }
140
+ }
141
+
142
+ function _emptyHandle(reason) {
143
+ return {
144
+ enabled: false, file: null, fd: null, runKey: null,
145
+ recovered: new Map(), order: [], written: new Set(),
146
+ discarded: false, reason,
147
+ };
148
+ }
149
+
150
+ function _headerLine(runKey) {
151
+ return JSON.stringify({ v: FORMAT, runKey }) + '\n';
152
+ }
153
+
154
+ // Read forward from a byte offset, keeping records while they verify. Returns
155
+ // the offset of the first byte that did NOT verify, so the caller can truncate.
156
+ function _recover(handle, file, runKey) {
157
+ let buf;
158
+ try { buf = fs.readFileSync(file); }
159
+ catch { return -1; } // no file yet
160
+ const text = buf.toString('utf8');
161
+ const nl = text.indexOf('\n');
162
+ if (nl < 0) return 0;
163
+ let header = null;
164
+ try { header = JSON.parse(text.slice(0, nl)); } catch { return 0; }
165
+ if (!header || header.v !== FORMAT || header.runKey !== runKey) return 0;
166
+
167
+ let offset = Buffer.byteLength(text.slice(0, nl + 1), 'utf8');
168
+ let cursor = nl + 1;
169
+ for (;;) {
170
+ const end = text.indexOf('\n', cursor);
171
+ if (end < 0) break; // torn tail: no terminating newline
172
+ const line = text.slice(cursor, end);
173
+ cursor = end + 1;
174
+ if (!line) { offset = Buffer.byteLength(text.slice(0, cursor), 'utf8'); continue; }
175
+ let rec;
176
+ try { rec = JSON.parse(line); } catch { break; }
177
+ if (!rec || typeof rec.f !== 'string' || typeof rec.d !== 'string') break;
178
+ if (rec.c !== _sha(rec.d)) break; // tampered or torn-then-patched
179
+ let payload;
180
+ try { payload = JSON.parse(rec.d); } catch { break; }
181
+ if (!handle.recovered.has(rec.f)) handle.order.push(rec.f);
182
+ handle.recovered.set(rec.f, payload);
183
+ offset = Buffer.byteLength(text.slice(0, cursor), 'utf8');
184
+ }
185
+ return offset;
186
+ }
187
+
188
+ /**
189
+ * Open (or start) the checkpoint for `scanRoot` under `runKey`. Never throws.
190
+ * A handle whose `enabled` is false silently no-ops through the rest of the API.
191
+ */
192
+ export function openCheckpoint(scanRoot, { runKey } = {}) {
193
+ if (!scanRoot || !runKey) return _emptyHandle('no-run-key');
194
+ const handle = _emptyHandle(null);
195
+ try {
196
+ const dir = path.join(scanRoot, STATE_DIR);
197
+ fs.mkdirSync(dir, { recursive: true });
198
+ const file = checkpointPath(scanRoot);
199
+ handle.file = file;
200
+ handle.runKey = runKey;
201
+
202
+ const keepBytes = _recover(handle, file, runKey);
203
+ if (keepBytes <= 0) {
204
+ // Absent, foreign, or unreadable — start clean. Conservative by design.
205
+ handle.recovered.clear();
206
+ handle.order.length = 0;
207
+ handle.discarded = keepBytes === 0;
208
+ fs.writeFileSync(file, _headerLine(runKey));
209
+ } else {
210
+ // Drop any torn tail so appends land after the last verified record.
211
+ try {
212
+ const size = fs.statSync(file).size;
213
+ if (size !== keepBytes) fs.truncateSync(file, keepBytes);
214
+ } catch { /* best-effort */ }
215
+ }
216
+
217
+ handle.fd = fs.openSync(file, 'a');
218
+ handle.enabled = true;
219
+ } catch (e) {
220
+ try { if (handle.fd !== null) fs.closeSync(handle.fd); } catch { /* ignore */ }
221
+ return _emptyHandle(String((e && e.message) || e));
222
+ }
223
+ return handle;
224
+ }
225
+
226
+ /**
227
+ * Durably record that `relPath` is fully analysed, along with everything that
228
+ * analysis produced. `findings` is the per-file payload object (see the engine
229
+ * call site); it must be plain JSON data. Returns true only if the record is on
230
+ * disk and fsync'd.
231
+ */
232
+ export function recordFileDone(handle, relPath, findings) {
233
+ if (!handle || !handle.enabled || handle.fd === null || typeof relPath !== 'string') return false;
234
+ try {
235
+ if (!_jsonSafe(findings)) return false;
236
+ const d = JSON.stringify(findings === undefined ? null : findings);
237
+ if (typeof d !== 'string') return false;
238
+ const line = JSON.stringify({ f: relPath, c: _sha(d), d }) + '\n';
239
+ fs.writeSync(handle.fd, line);
240
+ fs.fsyncSync(handle.fd);
241
+ handle.written.add(relPath);
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ /** Files already analysed — recovered from a prior run plus written by this one. */
249
+ export function completedFiles(handle) {
250
+ const out = new Set();
251
+ if (!handle) return out;
252
+ for (const f of handle.recovered ? handle.recovered.keys() : []) out.add(f);
253
+ for (const f of handle.written || []) out.add(f);
254
+ return out;
255
+ }
256
+
257
+ /** Recovered per-file payloads, in the order they were originally recorded. */
258
+ export function resumeFindings(handle) {
259
+ if (!handle || !handle.recovered) return [];
260
+ return (handle.order || []).map(file => ({ file, findings: handle.recovered.get(file) }));
261
+ }
262
+
263
+ /**
264
+ * Close the handle. `complete: true` means the scan finished — the checkpoint is
265
+ * removed so the next run cannot resume state that has already been consumed.
266
+ */
267
+ export function closeCheckpoint(handle, { complete = false } = {}) {
268
+ if (!handle || !handle.enabled) return false;
269
+ let ok = true;
270
+ try { if (handle.fd !== null) fs.closeSync(handle.fd); } catch { ok = false; }
271
+ handle.fd = null;
272
+ handle.enabled = false;
273
+ if (complete && handle.file) {
274
+ try { fs.rmSync(handle.file, { force: true }); } catch { ok = false; }
275
+ }
276
+ return ok;
277
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
- import * as yaml from 'js-yaml';
10
+ import * as yaml from '../util/yaml.js';
11
11
  import { statePath, safeWriteState } from './state-dir.js';
12
12
 
13
13
  const MS_PER_DAY = 86400000;