@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +28 -3
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/util/string-memo.js +38 -0
|
@@ -277,6 +277,24 @@ export function candidateCommitsForLine(scanRoot, file, line, { since } = {}) {
|
|
|
277
277
|
return [...new Set(shas)];
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The names a file had before it was last renamed or moved, nearest first (E7-02). `git log --follow` reports each rename as `R<score> old new`; chaining
|
|
282
|
+
* them back gives every earlier name, so a replay at an old commit can look for the file where it used to be. Empty for a file that was never renamed or
|
|
283
|
+
* where the history cannot be read. Not recorded: copies, which git reports only with extra flags and which do not rename a file.
|
|
284
|
+
*/
|
|
285
|
+
export function renameChain(scanRoot, file) {
|
|
286
|
+
const rel = _relPath(scanRoot, file);
|
|
287
|
+
if (!rel) return [];
|
|
288
|
+
const r = _run(scanRoot, ['log', '--follow', '-M', '--name-status', '--format=commit %H', '--no-textconv', '--', rel]);
|
|
289
|
+
if (!r.ok) return [];
|
|
290
|
+
const names = []; let current = rel;
|
|
291
|
+
for (const ln of r.stdout.split('\n')) {
|
|
292
|
+
const m = /^R\d*\t([^\t]+)\t([^\t]+)$/.exec(ln);
|
|
293
|
+
if (m && m[2] === current) { names.push(m[1]); current = m[1]; }
|
|
294
|
+
}
|
|
295
|
+
return names;
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
export function candidateCommitsForFile(scanRoot, file, { since } = {}) {
|
|
281
299
|
const rel = _relPath(scanRoot, file);
|
|
282
300
|
if (!rel) return [];
|
|
@@ -311,9 +329,75 @@ export function getRootCommits(scanRoot) {
|
|
|
311
329
|
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
312
330
|
}
|
|
313
331
|
|
|
332
|
+
// ---- one blame per file, not one per line (E7-02) ---------------------------------------------------------------------------------
|
|
333
|
+
//
|
|
334
|
+
// Resolving a finding blames the finding's line, and again for every evidence line on its path. A file with a dozen findings therefore started a dozen or
|
|
335
|
+
// more `git blame` processes, and on a large repository that is what used up the provenance time budget before the later findings were reached
|
|
336
|
+
// (`budget_exhausted`). Attribution of a line does not depend on which other lines were asked about, so the second time a file is asked about it is blamed
|
|
337
|
+
// whole, once, and every later line is answered from memory. The first question about a file still uses the cheap single-line form, so a file with one
|
|
338
|
+
// finding costs what it always did. The answer is keyed by the file's size and modification time, so a file that changed mid-scan is blamed again.
|
|
339
|
+
const _fileBlame = new Map();
|
|
340
|
+
const _seenOnce = new Set();
|
|
341
|
+
const WHOLE_FILE_MAX_LINES = 20000;
|
|
342
|
+
const GIT_WHOLE_FILE_TIMEOUT_MS = 20000;
|
|
343
|
+
|
|
344
|
+
function parseBlamePorcelain(out) {
|
|
345
|
+
const lines = new Map(); const metaBySha = new Map();
|
|
346
|
+
const rows = out.split('\n');
|
|
347
|
+
let cur = null;
|
|
348
|
+
for (let i = 0; i < rows.length; i++) {
|
|
349
|
+
const r = rows[i];
|
|
350
|
+
if (cur === null) {
|
|
351
|
+
const m = /^([0-9a-f]{40,64}) (\d+) (\d+)(?: (\d+))?$/.exec(r);
|
|
352
|
+
if (m) { cur = { sha: m[1], final: Number(m[3]) }; if (!metaBySha.has(cur.sha)) metaBySha.set(cur.sha, { commit: cur.sha }); }
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (r.startsWith('\t')) {
|
|
356
|
+
const meta = metaBySha.get(cur.sha);
|
|
357
|
+
lines.set(cur.final, /^0+$/.test(cur.sha) ? { commit: null, uncommitted: true } : { ...meta });
|
|
358
|
+
cur = null; continue;
|
|
359
|
+
}
|
|
360
|
+
const meta = metaBySha.get(cur.sha);
|
|
361
|
+
if (r.startsWith('author ')) meta.authorName = r.slice(7);
|
|
362
|
+
else if (r.startsWith('author-mail ')) meta.authorEmail = r.slice(12).replace(/[<>]/g, '');
|
|
363
|
+
else if (r.startsWith('author-time ')) meta.authorDate = new Date(parseInt(r.slice(12), 10) * 1000).toISOString();
|
|
364
|
+
else if (r.startsWith('summary ')) meta.summary = r.slice(8);
|
|
365
|
+
}
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function blameWholeFile(scanRoot, rel) {
|
|
370
|
+
const abs = path.join(scanRoot, rel);
|
|
371
|
+
let st; try { st = fs.statSync(abs); } catch { return null; }
|
|
372
|
+
const key = `${st.size}:${st.mtimeMs}`;
|
|
373
|
+
const cacheKey = `${scanRoot}\0${rel}`;
|
|
374
|
+
const hit = _fileBlame.get(cacheKey);
|
|
375
|
+
if (hit && hit.key === key) return hit.lines;
|
|
376
|
+
if (st.size > 2 * 1024 * 1024) return null;
|
|
377
|
+
let r;
|
|
378
|
+
try {
|
|
379
|
+
const stdout = cp.execFileSync('git', hardenGitArgs(['blame', '--porcelain', '--no-textconv', '--', rel]), { cwd: scanRoot, encoding: 'utf8', timeout: GIT_WHOLE_FILE_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024, env: hardenGitEnv() });
|
|
380
|
+
r = { ok: true, stdout };
|
|
381
|
+
} catch { r = { ok: false }; }
|
|
382
|
+
if (!r.ok) return null;
|
|
383
|
+
const lines = parseBlamePorcelain(r.stdout);
|
|
384
|
+
if (lines.size === 0 || lines.size > WHOLE_FILE_MAX_LINES) return null;
|
|
385
|
+
_fileBlame.set(cacheKey, { key, lines });
|
|
386
|
+
return lines;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Forget remembered blames (tests, and a long-lived process that wants the memory back). */
|
|
390
|
+
export function clearBlameCache() { _fileBlame.clear(); _seenOnce.clear(); }
|
|
391
|
+
|
|
314
392
|
export function blameLine(scanRoot, file, line) {
|
|
315
393
|
const rel = _relPath(scanRoot, file);
|
|
316
394
|
if (!rel || !line || line < 1) return null;
|
|
395
|
+
const once = `${scanRoot}\0${rel}`;
|
|
396
|
+
if (process.env.AGENTIC_SECURITY_NO_BLAME_CACHE !== '1' && _seenOnce.has(once)) {
|
|
397
|
+
const whole = blameWholeFile(scanRoot, rel);
|
|
398
|
+
const m = whole && whole.get(line);
|
|
399
|
+
if (m) return { ...m };
|
|
400
|
+
} else _seenOnce.add(once);
|
|
317
401
|
// `--no-textconv`: VERIFIED exploitable without it — `git blame` applies
|
|
318
402
|
// a hostile `.gitattributes` textconv driver by default in current git.
|
|
319
403
|
const r = _run(scanRoot, ['blame', '-L', `${line},${line}`, '--porcelain', '--no-textconv', '--', rel]);
|
|
@@ -30,6 +30,12 @@ import { checkAbsentInSomeParent, detectRevert, detectCherryPick } from './dag-w
|
|
|
30
30
|
import { loadRepoLineage } from './repo-lineage.js';
|
|
31
31
|
import { resolveAIAuthorship } from './ai-authorship.js';
|
|
32
32
|
|
|
33
|
+
// Findings produced by the deep (IR taint) layer exist only when that layer runs. A replay that skips it can never see such a finding at ANY commit, so origin
|
|
34
|
+
// resolution would end "never confirmed" for every one of them (78 of 559 findings in this repository's own scan did). The same reasoning is applied to
|
|
35
|
+
// Haskell and Nix in predicate-replay.js.
|
|
36
|
+
const DEEP_PARSERS = new Set(['IR-TAINT', 'IR-TAINT-IFDS', 'IR-PRIVACY-TAINT']);
|
|
37
|
+
const needsDeepReplay = (finding) => !!finding && DEEP_PARSERS.has(finding.parser);
|
|
38
|
+
|
|
33
39
|
function relevantFiles(finding) {
|
|
34
40
|
const files = new Set();
|
|
35
41
|
if (finding.file) files.add(finding.file);
|
|
@@ -224,7 +230,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
224
230
|
const replayCache = new Map();
|
|
225
231
|
const replay = (sha) => {
|
|
226
232
|
if (replayCache.has(sha)) return replayCache.get(sha);
|
|
227
|
-
const p = replayAt(scanRoot, sha, files, stableId);
|
|
233
|
+
const p = replayAt(scanRoot, sha, files, stableId, { deep: needsDeepReplay(finding) });
|
|
228
234
|
replayCache.set(sha, p);
|
|
229
235
|
return p;
|
|
230
236
|
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { runFullScan, _snapshotSuppressionLog, _restoreSuppressionLog } from '../../engine.js';
|
|
13
13
|
import { computeStableId } from '../stable-id.js';
|
|
14
|
-
import { getBlobAtCommit, listFilesAtCommit } from './git-evidence.js';
|
|
14
|
+
import { getBlobAtCommit, listFilesAtCommit, renameChain } from './git-evidence.js';
|
|
15
15
|
|
|
16
16
|
// Task 11 concurrency fix: `coordinator.js` resolves several findings' origins
|
|
17
17
|
// CONCURRENTLY (its own comment: "the scheduler runs these four at a time"),
|
|
@@ -57,7 +57,11 @@ export function _runExclusive(fn) {
|
|
|
57
57
|
return run;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} [opts] `deep` replays with the deep layer on. `renames` follows a file back through renames and moves: at a commit where the current path
|
|
62
|
+
* does not exist, the file's earlier names are tried, and the content is read under the CURRENT path so the replayed finding keeps the identity it has now.
|
|
63
|
+
*/
|
|
64
|
+
export async function replayAt(scanRoot, sha, files, targetStableId, opts = {}) {
|
|
61
65
|
const fileContents = {};
|
|
62
66
|
// A NixOS option is decided by every module the configuration imports, including ones deleted later, so a Nix
|
|
63
67
|
// finding is replayed against ALL Nix sources and the lock file as they existed at that commit.
|
|
@@ -65,8 +69,14 @@ export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
|
65
69
|
const all = listFilesAtCommit(scanRoot, sha, /(?:\.nix|(?:^|\/)flake\.lock)$/i);
|
|
66
70
|
files = [...new Set([...files, ...all])];
|
|
67
71
|
}
|
|
72
|
+
let chains = null;
|
|
68
73
|
for (const f of files) {
|
|
69
|
-
|
|
74
|
+
let content = getBlobAtCommit(scanRoot, sha, f);
|
|
75
|
+
if (content == null) {
|
|
76
|
+
chains = chains || new Map();
|
|
77
|
+
if (!chains.has(f)) chains.set(f, renameChain(scanRoot, f));
|
|
78
|
+
for (const old of chains.get(f)) { content = getBlobAtCommit(scanRoot, sha, old); if (content != null) break; }
|
|
79
|
+
}
|
|
70
80
|
if (content != null) fileContents[f] = content;
|
|
71
81
|
}
|
|
72
82
|
if (Object.keys(fileContents).length === 0) {
|
|
@@ -108,7 +118,7 @@ export async function replayAt(scanRoot, sha, files, targetStableId) {
|
|
|
108
118
|
// message for methodology.
|
|
109
119
|
// Haskell/Nix findings come from the IR-taint layer, which only runs in deep mode: a replay that skips it
|
|
110
120
|
// can never see the finding at ANY commit, so origin resolution would end "never confirmed" for every one.
|
|
111
|
-
const _langDeep = Object.keys(fileContents).some((f) => /\.(?:l?hs|nix)$/i.test(f)) ? { deep: true } : {};
|
|
121
|
+
const _langDeep = (opts.deep || Object.keys(fileContents).some((f) => /\.(?:l?hs|nix)$/i.test(f))) ? { deep: true } : {};
|
|
112
122
|
return await runFullScan({ fileContents, scanRoot, provenance: false, skipAnnotators: true, ..._langDeep }, () => {});
|
|
113
123
|
} finally {
|
|
114
124
|
_restoreSuppressionLog(_suppSnapshot);
|
|
@@ -36,6 +36,8 @@ import * as readline from 'node:readline';
|
|
|
36
36
|
import { createReadStream } from 'node:fs';
|
|
37
37
|
|
|
38
38
|
import { statePath } from './state-dir.js';
|
|
39
|
+
import { loadTraceMeta } from './runtime-trace-ingest.js';
|
|
40
|
+
import { linkObservationToSnapshot, normalizeIdentity } from '../lineage/deployment-identity.js';
|
|
39
41
|
const DEFAULT_TRACE_NAMES = ['runtime-trace.jsonl', 'runtime.jsonl', 'ebpf-trace.jsonl'];
|
|
40
42
|
const DEFAULT_OBSERVATION_WINDOW_DAYS = 30;
|
|
41
43
|
|
|
@@ -55,7 +57,12 @@ export async function loadTrace(scanRoot, opts = {}) {
|
|
|
55
57
|
fileLinesObserved: new Map(), // file → Set<line>
|
|
56
58
|
syscallsObserved: new Set(),
|
|
57
59
|
recordCount: 0,
|
|
60
|
+
// E5-02: with a snapshot identity, each record is compared with it. A record from a different build is dropped and counted; one that carries no
|
|
61
|
+
// comparable identity is kept as unattested; one that matches is counted as matched.
|
|
62
|
+
recordsMatched: 0, recordsUnattested: 0, recordsMismatched: 0,
|
|
58
63
|
};
|
|
64
|
+
trace.meta = loadTraceMeta(chosen);
|
|
65
|
+
const snap = opts.snapshotIdentity ? normalizeIdentity(opts.snapshotIdentity) : null;
|
|
59
66
|
const window = (opts.windowDays || DEFAULT_OBSERVATION_WINDOW_DAYS) * 86400_000;
|
|
60
67
|
const now = Date.now();
|
|
61
68
|
const stream = createReadStream(chosen, { encoding: 'utf8' });
|
|
@@ -68,6 +75,11 @@ export async function loadTrace(scanRoot, opts = {}) {
|
|
|
68
75
|
const tsMs = Date.parse(r.ts);
|
|
69
76
|
if (Number.isFinite(tsMs) && now - tsMs > window) continue;
|
|
70
77
|
}
|
|
78
|
+
if (snap) {
|
|
79
|
+
const link = linkObservationToSnapshot({ commit: r.commit, buildHash: r.buildHash, imageDigest: r.imageDigest, sbomRef: r.sbomRef, environment: r.environment }, snap);
|
|
80
|
+
if (link.state === 'mismatched') { trace.recordsMismatched++; continue; }
|
|
81
|
+
if (link.state === 'matched') trace.recordsMatched++; else trace.recordsUnattested++;
|
|
82
|
+
} else trace.recordsUnattested++;
|
|
71
83
|
trace.recordCount++;
|
|
72
84
|
switch (r.kind) {
|
|
73
85
|
case 'function-call':
|
|
@@ -138,38 +150,72 @@ export function findingObservedInRuntime(finding, trace) {
|
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
/**
|
|
141
|
-
* Annotate
|
|
142
|
-
* Demotion: critical → high, high → medium, medium → low.
|
|
153
|
+
* Annotate every finding with what the runtime trace says about it, and say what that does NOT mean.
|
|
143
154
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* is
|
|
155
|
+
* `runtimeObserved` keeps its three values (true, false, 'unknown'); `runtimeEvidence` states them in words:
|
|
156
|
+
* observed a function, line or route on the finding's path appears in the trace
|
|
157
|
+
* not_observed_in_window nothing on its path appears in the window. This is a statement about the window and the instrumentation, NOT a statement
|
|
158
|
+
* that the code is unreachable or safe: low traffic, a rare path, an uninstrumented file and an incomplete window all look
|
|
159
|
+
* the same from here
|
|
160
|
+
* unknown no trace, or the file appears with no matching line
|
|
161
|
+
*
|
|
162
|
+
* SEVERITY IS NOT CHANGED BY DEFAULT. This module used to lower the severity of every unobserved finding one tier. Absence of observation cannot
|
|
163
|
+
* justify that (and the lineage observation path never did), so demotion is now an explicit, opt-in policy: `opts.demote === true` or
|
|
164
|
+
* AGENTIC_SECURITY_RUNTIME_DEMOTE=1. Even then it needs cited evidence: the trace must hold at least `minRecords` records, the finding must be rooted in a
|
|
165
|
+
* route, and that route must be absent from a trace that does hold route hits (traffic reached the service and did not reach this route). A finding that
|
|
166
|
+
* fails any condition is left as it is.
|
|
167
|
+
* A demoted finding records the evidence under `_runtimeDemotionEvidence`; a finding is never removed.
|
|
147
168
|
*/
|
|
148
169
|
export async function annotateRuntimeCorrelation(scanRoot, findings, opts = {}) {
|
|
149
|
-
if (!Array.isArray(findings)) return { observed: 0, dead: 0, unknown: 0 };
|
|
170
|
+
if (!Array.isArray(findings)) return { observed: 0, dead: 0, unknown: 0, demoted: 0 };
|
|
150
171
|
const trace = await loadTrace(scanRoot, opts);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
const windowDays = opts.windowDays || DEFAULT_OBSERVATION_WINDOW_DAYS;
|
|
173
|
+
if (!trace || trace.recordCount === 0) {
|
|
174
|
+
// no file, or nothing usable in it (empty, outside the window, or every record from another build): there is nothing to compare a finding with
|
|
175
|
+
const basis = !trace ? 'no runtime trace was available' : (trace.recordsMismatched > 0 ? `all ${trace.recordsMismatched} usable record(s) came from a different build` : 'the trace holds no records inside the window');
|
|
176
|
+
for (const f of findings) { f.runtimeObserved = 'unknown'; f.runtimeEvidence = { state: 'unknown', basis, windowDays, recordCount: 0, ...(trace ? { recordsMismatched: trace.recordsMismatched } : {}) }; }
|
|
177
|
+
return { observed: 0, dead: 0, unknown: findings.length, demoted: 0, trace: trace ? { recordCount: 0, path: trace.path, recordsMismatched: trace.recordsMismatched } : null };
|
|
154
178
|
}
|
|
155
|
-
|
|
179
|
+
const demote = opts.demote === true || process.env.AGENTIC_SECURITY_RUNTIME_DEMOTE === '1';
|
|
180
|
+
const minRecords = Number.isInteger(opts.minRecords) ? opts.minRecords : 100;
|
|
181
|
+
let observed = 0, dead = 0, unknown = 0, demoted = 0;
|
|
156
182
|
const demoteLadder = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
157
183
|
for (const f of findings) {
|
|
158
|
-
|
|
184
|
+
let v = findingObservedInRuntime(f, trace);
|
|
185
|
+
// E5-03: a trace ingested with sampling below 1, or with no declared sampling, cannot show that something did NOT run. Absence becomes unknown.
|
|
186
|
+
if (v === false && trace.meta && trace.meta.sampleRate !== 1) v = 'unknown';
|
|
159
187
|
f.runtimeObserved = v;
|
|
188
|
+
const file = f.file || (f.sink && f.sink.file);
|
|
189
|
+
f.runtimeEvidence = v === true ? { state: 'observed', basis: 'a function, line or route on the finding\'s path is in the trace', windowDays, recordCount: trace.recordCount, corroboration: (opts.snapshotIdentity && trace.recordsMatched > 0 && trace.recordsUnattested === 0) ? 'corroborated' : 'unattested', recordsMismatched: trace.recordsMismatched }
|
|
190
|
+
: v === false ? { state: 'not_observed_in_window', basis: 'nothing on the finding\'s path is in the trace', windowDays, recordCount: trace.recordCount, meaning: 'absence of observation is not evidence that the code is unreachable or safe' }
|
|
191
|
+
: { state: 'unknown', basis: 'the trace cannot say (the file appears but no line on the path matches, or there is no trace)', windowDays, recordCount: trace.recordCount };
|
|
192
|
+
f.runtimeEvidence.sampling = samplingOf(trace);
|
|
160
193
|
if (v === true) observed++;
|
|
161
194
|
else if (v === false) {
|
|
162
195
|
dead++;
|
|
163
|
-
// Demote one tier — the finding is real but unobserved in 30 days
|
|
164
|
-
// of production traffic, so it's not P0.
|
|
165
196
|
const next = demoteLadder[f.severity];
|
|
166
|
-
|
|
197
|
+
// The cited evidence is TRAFFIC ON THE FINDING'S OWN ROUTE'S NEIGHBOURS: the finding is rooted in a route, the trace holds route hits, and this
|
|
198
|
+
// route is not among them. A finding with no route, or a trace with no route hits, has nothing to cite and is left alone.
|
|
199
|
+
const routePath = f._inRoute && f._inRoute.path;
|
|
200
|
+
const routeEvidence = !!(routePath && trace.routesObserved.size > 0 && !trace.routesObserved.has(routePath));
|
|
201
|
+
if (demote && next && routeEvidence && trace.recordCount >= minRecords) {
|
|
167
202
|
f._runtimeDemoted = f.severity;
|
|
203
|
+
f._runtimeDemotionEvidence = { policy: 'opt-in runtime demotion', basis: 'route-rooted finding whose route was not hit while other routes were', route: routePath, routesObserved: trace.routesObserved.size, recordCount: trace.recordCount, minRecords, windowDays, tracePath: trace.path };
|
|
168
204
|
f.severity = next;
|
|
205
|
+
demoted++;
|
|
169
206
|
}
|
|
170
207
|
} else unknown++;
|
|
171
208
|
}
|
|
172
|
-
return { observed, dead, unknown, trace: { recordCount: trace.recordCount, path: trace.path } };
|
|
209
|
+
return { observed, dead, unknown, demoted, trace: { recordCount: trace.recordCount, path: trace.path } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** How completely the trace sees the service: 'complete', 'sampled' (with the rate), or 'undisclosed'. */
|
|
213
|
+
export function samplingOf(trace) {
|
|
214
|
+
const m = trace && trace.meta;
|
|
215
|
+
if (!m) return { state: 'undisclosed', basis: 'the trace came with no ingestion record, so how much traffic it samples is not known' };
|
|
216
|
+
if (m.sampleRate === 1) return { state: 'complete', rate: 1 };
|
|
217
|
+
if (typeof m.sampleRate === 'number') return { state: 'sampled', rate: m.sampleRate, basis: 'not observed cannot be concluded from a sampled trace' };
|
|
218
|
+
return { state: 'undisclosed', basis: 'the collector did not declare a sampling rate' };
|
|
173
219
|
}
|
|
174
220
|
|
|
175
221
|
export const _internals = { DEFAULT_TRACE_NAMES, DEFAULT_OBSERVATION_WINDOW_DAYS };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Side-by-side static / runtime explanation of one finding (E5-07).
|
|
2
|
+
//
|
|
3
|
+
// One object, built once from one snapshot, feeds every surface: the CLI JSON, the SARIF result property, the explorer's two panes, and the export.
|
|
4
|
+
// Because they all read the same object, they cannot disagree, and because the object carries a digest of its own content, a pane built from a
|
|
5
|
+
// different snapshot is detected (`panesInSync`) instead of being shown beside the other.
|
|
6
|
+
//
|
|
7
|
+
// static what the code says: the finding's static reachability level and the edges of its path
|
|
8
|
+
// runtime what a running build showed: per-edge state, build-match counts, sampling, and the observations that are MISSING, each named
|
|
9
|
+
// compare the path's corroboration (pathCorroboration): corroborated only when every edge is `both`
|
|
10
|
+
//
|
|
11
|
+
// DETERMINISTIC. No clock, no randomness, keys in a fixed order, arrays sorted: the same inputs give byte-identical output and the same digest.
|
|
12
|
+
// OFFLINE. Nothing here reads the network; the inputs are values already computed.
|
|
13
|
+
// PRIVATE. The explanation is built field by field from normalized values (routes with ids generalised, symbols, counts, states). No raw runtime
|
|
14
|
+
// record is embedded, and `exportExplanation` scans every string it is about to emit with the same canary patterns the ingestion step uses and
|
|
15
|
+
// REFUSES to export if one matches.
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { canaryHit } from './runtime-trace-ingest.js';
|
|
18
|
+
import { pathCorroboration } from './graph-reconciliation.js';
|
|
19
|
+
import { buildEvidenceLevels } from './exploitability-evidence.js';
|
|
20
|
+
|
|
21
|
+
const EXPLANATION_SCHEMA = 'agentic-security/runtime-explanation@1';
|
|
22
|
+
const SKIP_CANARY = new Set(['commit', 'buildHash', 'imageDigest', 'digest', 'snapshotDigest']);
|
|
23
|
+
|
|
24
|
+
const canon = (v) => (Array.isArray(v) ? v.map(canon) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v);
|
|
25
|
+
const digestOf = (o) => createHash('sha256').update(JSON.stringify(canon(o))).digest('hex');
|
|
26
|
+
|
|
27
|
+
function missingFor(edge) {
|
|
28
|
+
if (!edge) return 'the path edge is not in the reconciled graph: no runtime statement is possible';
|
|
29
|
+
if (edge.state === 'both') return null;
|
|
30
|
+
if (edge.state === 'conflicted') return `runtime evidence contradicts the static graph for ${edge.key}`;
|
|
31
|
+
if (edge.state === 'unknown') return edge.reason || `runtime records for ${edge.key} cannot be tied to this snapshot`;
|
|
32
|
+
if (edge.mismatchedRecords > 0) return `only a different build was observed using ${edge.key}`;
|
|
33
|
+
return `no runtime record observed for ${edge.key}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} finding
|
|
38
|
+
* @param {{reconciled?:object, pathKeys?:string[], sampling?:object, snapshotIdentity?:object, windowDays?:number}} ctx
|
|
39
|
+
*/
|
|
40
|
+
export function buildRuntimeExplanation(finding, ctx = {}) {
|
|
41
|
+
const { levels } = buildEvidenceLevels(finding);
|
|
42
|
+
const keys = [...(ctx.pathKeys || [])];
|
|
43
|
+
const byKey = new Map(((ctx.reconciled && ctx.reconciled.edges) || []).map((e) => [e.key, e]));
|
|
44
|
+
const edges = keys.map((k) => { const e = byKey.get(k); return { key: k, state: e ? e.state : 'statically_inferred', confidence: e ? e.confidence : null, matchedRecords: e ? e.matchedRecords : 0, mismatchedRecords: e ? e.mismatchedRecords : 0, unattestedRecords: e ? e.unattestedRecords : 0 }; });
|
|
45
|
+
const missing = [];
|
|
46
|
+
if (!ctx.reconciled) missing.push('no runtime trace was reconciled with the static graph');
|
|
47
|
+
keys.forEach((k) => { const m = ctx.reconciled ? missingFor(byKey.get(k)) : null; if (m) missing.push(m); });
|
|
48
|
+
const sampling = ctx.sampling && ctx.sampling.state ? { state: ctx.sampling.state, ...(typeof ctx.sampling.rate === 'number' ? { rate: ctx.sampling.rate } : {}) } : { state: 'undisclosed' };
|
|
49
|
+
if (sampling.state !== 'complete') missing.push(sampling.state === 'sampled' ? `the trace samples ${sampling.rate} of traffic: absence of an observation proves nothing` : 'the trace does not say how much traffic it samples: absence of an observation proves nothing');
|
|
50
|
+
const s = ctx.snapshotIdentity || {};
|
|
51
|
+
const snapshot = { commit: s.commit ?? null, buildHash: s.buildHash ?? null, imageDigest: s.imageDigest ?? null, environment: s.environment ?? null, dirty: s.dirty === true };
|
|
52
|
+
const body = {
|
|
53
|
+
schema: EXPLANATION_SCHEMA,
|
|
54
|
+
findingId: finding && finding.id != null ? String(finding.id) : null,
|
|
55
|
+
// the refactor-stable id other evidence is keyed by; `findingId` alone is the scan-local id and changes when code moves
|
|
56
|
+
findingStableId: finding && finding.stableId != null ? String(finding.stableId) : null,
|
|
57
|
+
snapshot,
|
|
58
|
+
static: { staticallyReachable: levels.staticallyReachable, externallyReachable: levels.externallyReachable, pathEdges: keys },
|
|
59
|
+
runtime: { observed: levels.runtimeObserved, sampling, windowDays: ctx.windowDays ?? null, edges, missingObservations: [...new Set(missing)].sort() },
|
|
60
|
+
compare: ctx.reconciled ? pathCorroboration(ctx.reconciled, keys) : { state: 'uncorroborated', reason: 'no runtime evidence was supplied' },
|
|
61
|
+
offline: true,
|
|
62
|
+
};
|
|
63
|
+
return { ...body, digest: digestOf(body) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True when the explanation still hashes to its own digest, i.e. nothing was altered after it was built. */
|
|
67
|
+
export function explanationIntact(e) {
|
|
68
|
+
if (!e || typeof e !== 'object' || typeof e.digest !== 'string') return false;
|
|
69
|
+
const { digest, ...body } = e;
|
|
70
|
+
return digestOf(body) === digest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The properties a SARIF result or CLI JSON entry carries. A compact view of the same object, with the digest that ties it back. */
|
|
74
|
+
export function explanationSummary(e) {
|
|
75
|
+
return { schema: e.schema, digest: e.digest, compare: e.compare.state, runtimeObserved: e.runtime.observed.value, sampling: e.runtime.sampling.state, missingObservations: e.runtime.missingObservations.length, snapshotCommit: e.snapshot.commit };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The explorer's two panes, both derived from one explanation. Selecting an edge returns BOTH panes for that edge, stamped with the snapshot digest.
|
|
80
|
+
*/
|
|
81
|
+
export function buildViewModel(e) {
|
|
82
|
+
const rows = new Map(e.runtime.edges.map((r) => [r.key, r]));
|
|
83
|
+
return {
|
|
84
|
+
snapshotDigest: e.digest,
|
|
85
|
+
keys: [...rows.keys()],
|
|
86
|
+
select(key) {
|
|
87
|
+
const r = rows.get(key);
|
|
88
|
+
if (!r) return null;
|
|
89
|
+
return {
|
|
90
|
+
key, snapshotDigest: e.digest,
|
|
91
|
+
staticPane: { key, kind: key.split('|')[0], inStaticGraph: true, reachability: e.static.staticallyReachable.value },
|
|
92
|
+
runtimePane: { key, state: r.state, confidence: r.confidence, matched: r.matchedRecords, mismatched: r.mismatchedRecords, unattested: r.unattestedRecords, missing: r.state === 'both' ? null : missingFor({ key, ...r }) },
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Two panes may be shown together only when they are about the same key and were built from the same snapshot. */
|
|
99
|
+
export function panesInSync(sel) {
|
|
100
|
+
return !!sel && sel.staticPane.key === sel.runtimePane.key && sel.staticPane.key === sel.key && typeof sel.snapshotDigest === 'string';
|
|
101
|
+
}
|
|
102
|
+
export function selectionsInSync(a, b) { return !!a && !!b && a.snapshotDigest === b.snapshotDigest && a.key === b.key; }
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* JSON for sharing. Refuses (throws) when any string in it matches a secret or personal-data pattern.
|
|
106
|
+
*/
|
|
107
|
+
export function exportExplanation(e) {
|
|
108
|
+
if (!explanationIntact(e)) throw new Error('refusing to export: the explanation was altered after it was built');
|
|
109
|
+
const walk = (v, k) => {
|
|
110
|
+
if (typeof v === 'string') { if (!SKIP_CANARY.has(k)) { const hit = canaryHit(v); if (hit) throw new Error(`refusing to export: a value matches the ${hit} pattern`); } }
|
|
111
|
+
else if (Array.isArray(v)) v.forEach((x) => walk(x, k));
|
|
112
|
+
else if (v && typeof v === 'object') for (const [kk, x] of Object.entries(v)) walk(x, kk);
|
|
113
|
+
};
|
|
114
|
+
walk(e, null);
|
|
115
|
+
return JSON.stringify(canon(e), null, 2) + '\n';
|
|
116
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Opt-in, privacy-preserving ingestion of runtime observations (E5-03).
|
|
2
|
+
//
|
|
3
|
+
// A runtime collector can see far more than this scanner needs: request bodies, headers, tokens, user identifiers. The scanner needs only WHICH
|
|
4
|
+
// route, function or file was exercised, and in which build. This module is the one door between a collector's output and the trace file that
|
|
5
|
+
// runtime-correlation.js reads, and it lets through only that.
|
|
6
|
+
//
|
|
7
|
+
// RULES (each is a test)
|
|
8
|
+
// 1. Off by default. Nothing is ingested unless the project's runtime policy says `collect: true` (or the caller passes that policy in).
|
|
9
|
+
// 2. An allowlist, not a blocklist. A record is rebuilt field by field from the fields below; every other field is dropped, counted by NAME and
|
|
10
|
+
// never persisted, whatever it holds. A new field a collector invents is dropped until this list names it.
|
|
11
|
+
// 3. Values must look like what the field is: a route is a path, a symbol is a dotted identifier, a file is a relative path. A value that does not
|
|
12
|
+
// fit rejects the whole record.
|
|
13
|
+
// 4. A canary scan runs over every value that IS allowed through (a route like /users/jane@example.com/orders, a symbol that is really a token):
|
|
14
|
+
// an email, bearer token, JWT, cloud key, private key, card number (Luhn), SSN, or long opaque string rejects the whole record.
|
|
15
|
+
// Query strings and fragments never survive: the route is the path only, and id-like segments (numbers, UUIDs, long hex) become `:id`.
|
|
16
|
+
// 5. Retention: records older than `retentionDays` are removed from the file when it is next written, and by `applyRetention`.
|
|
17
|
+
// 6. Disclosure: a sidecar `runtime-trace.meta.json` records the policy, the declared sampling rate, what was dropped and rejected (counts by
|
|
18
|
+
// reason, never contents), and when. A trace with sampling below 1, or no declared sampling, can never support "not observed": see
|
|
19
|
+
// runtime-correlation.js. Everything works offline from local files.
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import { statePath } from './state-dir.js';
|
|
23
|
+
import { safeWriteState } from './state-dir.js';
|
|
24
|
+
|
|
25
|
+
export const POLICY_FILE = 'runtime-policy.json';
|
|
26
|
+
const TRACE_FILE = 'runtime-trace.jsonl';
|
|
27
|
+
const META_FILE = 'runtime-trace.meta.json';
|
|
28
|
+
const DEFAULT_RETENTION_DAYS = 30;
|
|
29
|
+
const ALLOWED_KINDS = Object.freeze(['function-call', 'route-hit', 'syscall', 'file-touch', 'service-call']);
|
|
30
|
+
export const ALLOWED_FIELDS = Object.freeze(['ts', 'kind', 'qid', 'route', 'fileRel', 'line', 'count', 'name', 'caller', 'service', 'peer', 'commit', 'buildHash', 'imageDigest', 'sbomRef', 'environment']);
|
|
31
|
+
|
|
32
|
+
const SYMBOL = /^[A-Za-z_$][\w$]*(?:[.:#/][A-Za-z_$][\w$]*)*(?:\(\))?$/;
|
|
33
|
+
const TOKEN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
34
|
+
const REL_PATH = /^(?!.*\.\.)(?!\/)[\w@.+/-]{1,300}$/;
|
|
35
|
+
const SYSCALL = /^[a-z_][a-z0-9_]{0,40}$/;
|
|
36
|
+
|
|
37
|
+
// Canary patterns: what a secret or personal datum looks like. A hit rejects the record. Deliberately broad: a falsely rejected record costs one
|
|
38
|
+
// observation; a falsely accepted one persists a secret.
|
|
39
|
+
const CANARIES = [
|
|
40
|
+
['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/],
|
|
41
|
+
['bearer-token', /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i],
|
|
42
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]*/],
|
|
43
|
+
['cloud-key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\bAIza[0-9A-Za-z_-]{30,}\b|\b(?:sk|pk|rk)_(?:live|test)_[0-9A-Za-z]{10,}\b|\bgh[pousr]_[A-Za-z0-9]{20,}\b|\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
|
|
44
|
+
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
45
|
+
['ssn', /\b\d{3}-\d{2}-\d{4}\b/],
|
|
46
|
+
['secret-assignment', /\b(?:password|passwd|pwd|secret|token|api[_-]?key|authorization)\s*[=:]\s*\S{3,}/i],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function luhn(digits) {
|
|
50
|
+
let sum = 0; let alt = false;
|
|
51
|
+
for (let i = digits.length - 1; i >= 0; i--) { let d = digits.charCodeAt(i) - 48; if (alt) { d *= 2; if (d > 9) d -= 9; } sum += d; alt = !alt; }
|
|
52
|
+
return sum % 10 === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Name of the first canary the string trips, or null. */
|
|
56
|
+
export function canaryHit(s) {
|
|
57
|
+
if (typeof s !== 'string') return null;
|
|
58
|
+
for (const [name, re] of CANARIES) if (re.test(s)) return name;
|
|
59
|
+
for (const m of s.matchAll(/\b\d(?:[ -]?\d){12,18}\b/g)) { const d = m[0].replace(/[ -]/g, ''); if (d.length >= 13 && d.length <= 19 && luhn(d)) return 'payment-card'; }
|
|
60
|
+
if (/[A-Za-z0-9+/_-]{40,}={0,2}/.test(s)) return 'opaque-string';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SECRET_CANARY_NAMES = new Set(['bearer-token', 'jwt', 'cloud-key', 'private-key', 'secret-assignment']);
|
|
65
|
+
/** Name of the first SECRET-shaped pattern in a text (keys, tokens, passwords, card numbers), or null. Narrower than canaryHit: personal data and opaque strings are not matched. */
|
|
66
|
+
export function secretCanaryHit(s) {
|
|
67
|
+
if (typeof s !== 'string') return null;
|
|
68
|
+
for (const [name, re] of CANARIES) if (SECRET_CANARY_NAMES.has(name) && re.test(s)) return name;
|
|
69
|
+
for (const m of s.matchAll(/\b\d(?:[ -]?\d){12,18}\b/g)) { const d = m[0].replace(/[ -]/g, ''); if (d.length >= 13 && d.length <= 19 && luhn(d)) return 'payment-card'; }
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The path of a route with the query and fragment removed and id-like segments generalised. Null when it is not a path. */
|
|
74
|
+
export function normalizeRoute(raw) {
|
|
75
|
+
if (typeof raw !== 'string' || raw.length > 300) return null;
|
|
76
|
+
let p = raw.trim();
|
|
77
|
+
const cut = p.search(/[?#]/); if (cut >= 0) p = p.slice(0, cut);
|
|
78
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/[^/]+(\/.*)?$/i.exec(p); if (m) p = m[1] || '/';
|
|
79
|
+
if (!p.startsWith('/') || /[\s<>"'\\]/.test(p)) return null;
|
|
80
|
+
return p.split('/').map((seg) => (/^\d+$/.test(seg) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg) || /^[0-9a-f]{16,}$/i.test(seg) ? ':id' : seg)).join('/');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rebuild one record from the allowlist.
|
|
85
|
+
* @returns {{record:object}|{rejected:string, dropped:string[]}} `dropped` lists the NAMES of fields not persisted (never their values)
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeRecord(raw) {
|
|
88
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { rejected: 'not-an-object', dropped: [] };
|
|
89
|
+
const dropped = Object.keys(raw).filter((k) => !ALLOWED_FIELDS.includes(k));
|
|
90
|
+
if (!ALLOWED_KINDS.includes(raw.kind)) return { rejected: 'unknown-kind', dropped };
|
|
91
|
+
const out = { kind: raw.kind };
|
|
92
|
+
const reject = (reason) => ({ rejected: reason, dropped });
|
|
93
|
+
if (raw.ts !== undefined) { if (typeof raw.ts !== 'string' || !Number.isFinite(Date.parse(raw.ts))) return reject('bad-timestamp'); out.ts = new Date(raw.ts).toISOString(); }
|
|
94
|
+
if (raw.qid !== undefined) { if (typeof raw.qid !== 'string' || raw.qid.length > 300 || !SYMBOL.test(raw.qid)) return reject('bad-symbol'); out.qid = raw.qid; }
|
|
95
|
+
if (raw.caller !== undefined) { if (typeof raw.caller !== 'string' || raw.caller.length > 300 || !SYMBOL.test(raw.caller)) return reject('bad-caller'); out.caller = raw.caller; }
|
|
96
|
+
for (const k of ['service', 'peer']) if (raw[k] !== undefined) { if (typeof raw[k] !== 'string' || !TOKEN.test(raw[k])) return reject(`bad-${k}`); out[k] = raw[k]; }
|
|
97
|
+
if (raw.route !== undefined) { const r = normalizeRoute(raw.route); if (!r) return reject('bad-route'); out.route = r; }
|
|
98
|
+
if (raw.fileRel !== undefined) { if (typeof raw.fileRel !== 'string' || !REL_PATH.test(raw.fileRel)) return reject('bad-file'); out.fileRel = raw.fileRel; }
|
|
99
|
+
if (raw.name !== undefined) { if (typeof raw.name !== 'string' || !SYSCALL.test(raw.name)) return reject('bad-name'); out.name = raw.name; }
|
|
100
|
+
for (const k of ['line', 'count']) if (raw[k] !== undefined) { if (!Number.isInteger(raw[k]) || raw[k] < 0 || raw[k] > 1e9) return reject(`bad-${k}`); out[k] = raw[k]; }
|
|
101
|
+
for (const k of ['commit', 'buildHash', 'imageDigest', 'sbomRef', 'environment']) if (raw[k] !== undefined) { if (typeof raw[k] !== 'string' || !TOKEN.test(raw[k])) return reject(`bad-${k}`); out[k] = raw[k]; }
|
|
102
|
+
// canary scan over everything that is about to be persisted, except the identity fields whose shape (hex, digest) is itself long and opaque
|
|
103
|
+
for (const [k, v] of Object.entries(out)) {
|
|
104
|
+
if (typeof v !== 'string' || ['kind', 'ts', 'commit', 'buildHash', 'imageDigest', 'sbomRef'].includes(k)) continue;
|
|
105
|
+
const hit = canaryHit(v);
|
|
106
|
+
if (hit) return reject(`canary:${hit}`);
|
|
107
|
+
}
|
|
108
|
+
if (raw.kind === 'service-call' && (!out.service || !out.peer)) return reject('service-call-without-endpoints');
|
|
109
|
+
if (raw.kind === 'route-hit' && !out.route) return reject('route-hit-without-route');
|
|
110
|
+
if (raw.kind === 'function-call' && !out.qid && !out.fileRel) return reject('function-call-without-symbol');
|
|
111
|
+
return { record: out, dropped };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read the project's runtime policy; null when absent or unreadable. */
|
|
115
|
+
function loadRuntimePolicy(scanRoot) {
|
|
116
|
+
try {
|
|
117
|
+
const p = JSON.parse(fs.readFileSync(statePath(scanRoot, POLICY_FILE), 'utf8'));
|
|
118
|
+
return p && typeof p === 'object' ? p : null;
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
|
|
123
|
+
|
|
124
|
+
/** Remove records older than the retention window from the trace file. Returns how many were removed. */
|
|
125
|
+
export function applyRetention(scanRoot, { retentionDays = DEFAULT_RETENTION_DAYS, now = Date.now() } = {}) {
|
|
126
|
+
const file = statePath(scanRoot, TRACE_FILE);
|
|
127
|
+
let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return { removed: 0, kept: 0 }; }
|
|
128
|
+
const keep = []; let removed = 0;
|
|
129
|
+
for (const line of text.split('\n')) {
|
|
130
|
+
if (!line.trim()) continue;
|
|
131
|
+
let r; try { r = JSON.parse(line); } catch { removed++; continue; }
|
|
132
|
+
const t = r && r.ts ? Date.parse(r.ts) : NaN;
|
|
133
|
+
if (Number.isFinite(t) && now - t > retentionDays * 86400_000) { removed++; continue; }
|
|
134
|
+
keep.push(line);
|
|
135
|
+
}
|
|
136
|
+
if (removed > 0 && !safeWriteState(file, keep.length ? keep.join('\n') + '\n' : '')) return { removed: 0, kept: keep.length + removed, error: 'could not rewrite the trace file' };
|
|
137
|
+
return { removed, kept: keep.length };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ingest collector records.
|
|
142
|
+
* @param {string} scanRoot
|
|
143
|
+
* @param {object[]} records
|
|
144
|
+
* @param {object} opts policy (overrides the policy file), sampleRate (declared by the collector, 0..1), now
|
|
145
|
+
* @returns {{ok:boolean, reason?:string, accepted:number, rejected:Object<string,number>, droppedFields:Object<string,number>, meta?:object}}
|
|
146
|
+
*/
|
|
147
|
+
export function ingestRuntimeRecords(scanRoot, records, opts = {}) {
|
|
148
|
+
const policy = opts.policy || loadRuntimePolicy(scanRoot);
|
|
149
|
+
if (!policy || policy.collect !== true) return { ok: false, reason: 'runtime collection is not enabled: set {"collect": true} in .agentic-security/runtime-policy.json', accepted: 0, rejected: {}, droppedFields: {} };
|
|
150
|
+
if (!Array.isArray(records)) return { ok: false, reason: 'records must be an array', accepted: 0, rejected: {}, droppedFields: {} };
|
|
151
|
+
const allowedKinds = Array.isArray(policy.kinds) ? policy.kinds.filter((k) => ALLOWED_KINDS.includes(k)) : ALLOWED_KINDS;
|
|
152
|
+
const retentionDays = Number.isInteger(policy.retentionDays) && policy.retentionDays > 0 ? policy.retentionDays : DEFAULT_RETENTION_DAYS;
|
|
153
|
+
const declared = opts.sampleRate !== undefined ? opts.sampleRate : policy.sampleRate;
|
|
154
|
+
const sampleRate = typeof declared === 'number' && declared > 0 && declared <= 1 ? declared : null;
|
|
155
|
+
const now = opts.now ?? Date.now();
|
|
156
|
+
const rejected = {}; const droppedFields = {}; const lines = [];
|
|
157
|
+
for (const raw of records) {
|
|
158
|
+
const n = normalizeRecord(raw);
|
|
159
|
+
for (const d of n.dropped || []) bump(droppedFields, d);
|
|
160
|
+
if (n.rejected) { bump(rejected, n.rejected.startsWith('canary:') ? n.rejected : n.rejected); continue; }
|
|
161
|
+
if (!allowedKinds.includes(n.record.kind)) { bump(rejected, 'kind-not-allowed-by-policy'); continue; }
|
|
162
|
+
if (!n.record.ts) n.record.ts = new Date(now).toISOString();
|
|
163
|
+
lines.push(JSON.stringify(n.record));
|
|
164
|
+
}
|
|
165
|
+
const file = statePath(scanRoot, TRACE_FILE);
|
|
166
|
+
const ret = applyRetention(scanRoot, { retentionDays, now });
|
|
167
|
+
let existing = ''; try { existing = fs.readFileSync(file, 'utf8'); } catch { /* none yet */ }
|
|
168
|
+
if (lines.length && !safeWriteState(file, existing + lines.join('\n') + '\n')) return { ok: false, reason: 'could not write the trace file (no project marker or state writes disabled)', accepted: 0, rejected, droppedFields };
|
|
169
|
+
const prior = (() => { try { return JSON.parse(fs.readFileSync(statePath(scanRoot, META_FILE), 'utf8')); } catch { return null; } })();
|
|
170
|
+
const meta = {
|
|
171
|
+
schema: 'agentic-security/runtime-trace-meta@1',
|
|
172
|
+
updatedAt: new Date(now).toISOString(),
|
|
173
|
+
retentionDays,
|
|
174
|
+
// null means the collector did not say. Both null and < 1 mean "absence of an observation proves nothing"
|
|
175
|
+
sampleRate,
|
|
176
|
+
samplingDisclosed: sampleRate !== null,
|
|
177
|
+
totals: {
|
|
178
|
+
accepted: ((prior && prior.totals && prior.totals.accepted) || 0) + lines.length,
|
|
179
|
+
rejected: sumInto((prior && prior.totals && prior.totals.rejected) || {}, rejected),
|
|
180
|
+
droppedFields: sumInto((prior && prior.totals && prior.totals.droppedFields) || {}, droppedFields),
|
|
181
|
+
expiredByRetention: ((prior && prior.totals && prior.totals.expiredByRetention) || 0) + ret.removed,
|
|
182
|
+
},
|
|
183
|
+
note: 'Counts only. No dropped or rejected value is stored.',
|
|
184
|
+
};
|
|
185
|
+
safeWriteState(statePath(scanRoot, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
186
|
+
return { ok: true, accepted: lines.length, rejected, droppedFields, expired: ret.removed, meta };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sumInto(base, add) { const o = { ...base }; for (const [k, v] of Object.entries(add)) o[k] = (o[k] || 0) + v; return o; }
|
|
190
|
+
|
|
191
|
+
/** The sidecar next to a trace file, or null. */
|
|
192
|
+
export function loadTraceMeta(traceFile) {
|
|
193
|
+
try { return JSON.parse(fs.readFileSync(path.join(path.dirname(traceFile), META_FILE), 'utf8')); } catch { return null; }
|
|
194
|
+
}
|