@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.
- package/CHANGELOG.md +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- 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
|
+
};
|
|
@@ -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
|
+
}
|