@clear-capabilities/agentic-security-scanner 0.79.0 → 0.80.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/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +90 -1
- package/dist/agentic-security.mjs +83 -83
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/.agentic-security/findings.json +104638 -0
- package/src/.agentic-security/last-scan.json +104638 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12562 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +784 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +143 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json +64004 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +7162 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +26 -1
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// eBPF runtime instrumentation correlation — Recommendation #5 of the
|
|
2
|
+
// world-class roadmap.
|
|
3
|
+
//
|
|
4
|
+
// The hardest false-positive class is "this code path is technically
|
|
5
|
+
// reachable but is DEAD in production." Static analysis can't distinguish
|
|
6
|
+
// dead code from reachable code; runtime observation can. This module
|
|
7
|
+
// consumes an eBPF trace dataset (produced by an out-of-band collector
|
|
8
|
+
// running in the customer's prod environment) and demotes findings
|
|
9
|
+
// whose call-graph paths were unobserved.
|
|
10
|
+
//
|
|
11
|
+
// Trace format (JSONL, one record per observation):
|
|
12
|
+
//
|
|
13
|
+
// { "ts": "2026-05-28T...", "host": "prod-host-1",
|
|
14
|
+
// "kind": "function-call", "qid": "com.acme.UserController.getUser",
|
|
15
|
+
// "fileRel": "src/main/java/com/acme/UserController.java", "line": 42,
|
|
16
|
+
// "count": 1234, "lastSeen": "2026-05-28T..." }
|
|
17
|
+
//
|
|
18
|
+
// Common record kinds:
|
|
19
|
+
// - "function-call": QID was invoked at least once in the trace window
|
|
20
|
+
// - "route-hit": HTTP route received traffic
|
|
21
|
+
// - "syscall": filesystem / network / process syscall fired
|
|
22
|
+
// - "file-touch": file was read / written
|
|
23
|
+
//
|
|
24
|
+
// The trace file lives at one of:
|
|
25
|
+
// .agentic-security/runtime-trace.jsonl (per-project, committed)
|
|
26
|
+
// $AGENTIC_SECURITY_RUNTIME_TRACE_PATH (override)
|
|
27
|
+
//
|
|
28
|
+
// Output: every finding gets a `runtimeObserved: true|false|unknown` field.
|
|
29
|
+
// true — at least one node on the finding's call-graph path appears in trace
|
|
30
|
+
// false — none of the nodes appear (finding's path is dead in observed window)
|
|
31
|
+
// unknown — no trace data available
|
|
32
|
+
|
|
33
|
+
import * as fs from 'node:fs';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
import * as readline from 'node:readline';
|
|
36
|
+
import { createReadStream } from 'node:fs';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TRACE_NAMES = ['runtime-trace.jsonl', 'runtime.jsonl', 'ebpf-trace.jsonl'];
|
|
39
|
+
const DEFAULT_OBSERVATION_WINDOW_DAYS = 30;
|
|
40
|
+
|
|
41
|
+
export async function loadTrace(scanRoot, opts = {}) {
|
|
42
|
+
const explicit = opts.tracePath || process.env.AGENTIC_SECURITY_RUNTIME_TRACE_PATH;
|
|
43
|
+
const candidates = explicit ? [explicit] : DEFAULT_TRACE_NAMES.map(n => path.join(scanRoot, '.agentic-security', n));
|
|
44
|
+
let chosen = null;
|
|
45
|
+
for (const c of candidates) {
|
|
46
|
+
if (fs.existsSync(c)) { chosen = c; break; }
|
|
47
|
+
}
|
|
48
|
+
if (!chosen) return null;
|
|
49
|
+
const trace = {
|
|
50
|
+
path: chosen,
|
|
51
|
+
qidsObserved: new Set(),
|
|
52
|
+
routesObserved: new Set(),
|
|
53
|
+
filesObserved: new Set(),
|
|
54
|
+
fileLinesObserved: new Map(), // file → Set<line>
|
|
55
|
+
syscallsObserved: new Set(),
|
|
56
|
+
recordCount: 0,
|
|
57
|
+
};
|
|
58
|
+
const window = (opts.windowDays || DEFAULT_OBSERVATION_WINDOW_DAYS) * 86400_000;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const stream = createReadStream(chosen, { encoding: 'utf8' });
|
|
61
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
62
|
+
for await (const line of rl) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
let r;
|
|
65
|
+
try { r = JSON.parse(line); } catch { continue; }
|
|
66
|
+
if (r.ts) {
|
|
67
|
+
const tsMs = Date.parse(r.ts);
|
|
68
|
+
if (Number.isFinite(tsMs) && now - tsMs > window) continue;
|
|
69
|
+
}
|
|
70
|
+
trace.recordCount++;
|
|
71
|
+
switch (r.kind) {
|
|
72
|
+
case 'function-call':
|
|
73
|
+
if (r.qid) trace.qidsObserved.add(r.qid);
|
|
74
|
+
if (r.fileRel && typeof r.line === 'number') {
|
|
75
|
+
let set = trace.fileLinesObserved.get(r.fileRel);
|
|
76
|
+
if (!set) { set = new Set(); trace.fileLinesObserved.set(r.fileRel, set); }
|
|
77
|
+
set.add(r.line);
|
|
78
|
+
}
|
|
79
|
+
if (r.fileRel) trace.filesObserved.add(r.fileRel);
|
|
80
|
+
break;
|
|
81
|
+
case 'route-hit':
|
|
82
|
+
if (r.route) trace.routesObserved.add(r.route);
|
|
83
|
+
break;
|
|
84
|
+
case 'syscall':
|
|
85
|
+
if (r.name) trace.syscallsObserved.add(r.name);
|
|
86
|
+
break;
|
|
87
|
+
case 'file-touch':
|
|
88
|
+
if (r.fileRel) trace.filesObserved.add(r.fileRel);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return trace;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Test whether a finding's call-graph path was observed in trace.
|
|
97
|
+
* Checks:
|
|
98
|
+
* 1. The finding's file appears in filesObserved (necessary condition)
|
|
99
|
+
* 2. AT LEAST ONE line on the finding's chain (source, sink, intermediate
|
|
100
|
+
* nodes) was observed in that file
|
|
101
|
+
* 3. The finding's containing function's qid appears in qidsObserved
|
|
102
|
+
*/
|
|
103
|
+
export function findingObservedInRuntime(finding, trace) {
|
|
104
|
+
if (!trace) return 'unknown';
|
|
105
|
+
// qid match (most specific)
|
|
106
|
+
if (finding.scope && trace.qidsObserved.has(finding.scope)) return true;
|
|
107
|
+
if (finding.functionQid && trace.qidsObserved.has(finding.functionQid)) return true;
|
|
108
|
+
// file + any-line on the chain match
|
|
109
|
+
const file = finding.file || (finding.sink && finding.sink.file);
|
|
110
|
+
if (file && trace.filesObserved.has(file)) {
|
|
111
|
+
const linesObserved = trace.fileLinesObserved.get(file);
|
|
112
|
+
if (linesObserved) {
|
|
113
|
+
const chainLines = [
|
|
114
|
+
finding.line,
|
|
115
|
+
...(finding.chain || []).map(s => s.line),
|
|
116
|
+
...(finding.taintPath || []).map(s => s.line),
|
|
117
|
+
].filter(n => typeof n === 'number');
|
|
118
|
+
for (const ln of chainLines) {
|
|
119
|
+
// Allow ±2 line tolerance for compiler reordering / minor inlining.
|
|
120
|
+
for (let off = -2; off <= 2; off++) {
|
|
121
|
+
if (linesObserved.has(ln + off)) return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// File appears but no line match — partial evidence. Don't claim
|
|
126
|
+
// observed; don't claim dead.
|
|
127
|
+
return 'unknown';
|
|
128
|
+
}
|
|
129
|
+
// route match — every finding inside a route handler whose route was hit.
|
|
130
|
+
if (finding._inRoute && finding._inRoute.path && trace.routesObserved.has(finding._inRoute.path)) return true;
|
|
131
|
+
if (finding.routeRooted && finding._inRoute && trace.routesObserved.size > 0) {
|
|
132
|
+
for (const r of trace.routesObserved) {
|
|
133
|
+
if (finding._inRoute.path && r.includes(finding._inRoute.path)) return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Annotate all findings with runtimeObserved + demote unobserved findings.
|
|
141
|
+
* Demotion: critical → high, high → medium, medium → low.
|
|
142
|
+
*
|
|
143
|
+
* Findings classified as unknown are left alone (no demotion). This is
|
|
144
|
+
* the principled position — absence of observation in a partial trace
|
|
145
|
+
* is not evidence of dead code.
|
|
146
|
+
*/
|
|
147
|
+
export async function annotateRuntimeCorrelation(scanRoot, findings, opts = {}) {
|
|
148
|
+
if (!Array.isArray(findings)) return { observed: 0, dead: 0, unknown: 0 };
|
|
149
|
+
const trace = await loadTrace(scanRoot, opts);
|
|
150
|
+
if (!trace) {
|
|
151
|
+
for (const f of findings) f.runtimeObserved = 'unknown';
|
|
152
|
+
return { observed: 0, dead: 0, unknown: findings.length, trace: null };
|
|
153
|
+
}
|
|
154
|
+
let observed = 0, dead = 0, unknown = 0;
|
|
155
|
+
const demoteLadder = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
156
|
+
for (const f of findings) {
|
|
157
|
+
const v = findingObservedInRuntime(f, trace);
|
|
158
|
+
f.runtimeObserved = v;
|
|
159
|
+
if (v === true) observed++;
|
|
160
|
+
else if (v === false) {
|
|
161
|
+
dead++;
|
|
162
|
+
// Demote one tier — the finding is real but unobserved in 30 days
|
|
163
|
+
// of production traffic, so it's not P0.
|
|
164
|
+
const next = demoteLadder[f.severity];
|
|
165
|
+
if (next) {
|
|
166
|
+
f._runtimeDemoted = f.severity;
|
|
167
|
+
f.severity = next;
|
|
168
|
+
}
|
|
169
|
+
} else unknown++;
|
|
170
|
+
}
|
|
171
|
+
return { observed, dead, unknown, trace: { recordCount: trace.recordCount, path: trace.path } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const _internals = { DEFAULT_TRACE_NAMES, DEFAULT_OBSERVATION_WINDOW_DAYS };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SBOM diff + dependency drift detection — Recommendation #10 of the
|
|
2
|
+
// world-class+2 plan.
|
|
3
|
+
//
|
|
4
|
+
// Tracks SBOMs across releases (keyed by git commit hash). On each scan,
|
|
5
|
+
// compares the current SBOM against the previous snapshot to surface
|
|
6
|
+
// drift before a CVE-publication catches it:
|
|
7
|
+
//
|
|
8
|
+
// - dependency-added new package since previous SBOM
|
|
9
|
+
// - dependency-removed package no longer present
|
|
10
|
+
// - dependency-version-bumped version changed
|
|
11
|
+
// - dependency-substitution SAME package name but different
|
|
12
|
+
// ecosystem / publisher / repo source
|
|
13
|
+
// (the SolarWinds / event-stream pattern)
|
|
14
|
+
// - dependency-deprecated transitioned to a deprecated state
|
|
15
|
+
//
|
|
16
|
+
// Suspicious additions (i.e., a new package that doesn't appear in any
|
|
17
|
+
// PR diff / commit message) get a higher severity tier than expected ones.
|
|
18
|
+
//
|
|
19
|
+
// Snapshots live at .agentic-security/sbom-history/<sha>.json. The
|
|
20
|
+
// engine writes the current snapshot at the end of every scan; this
|
|
21
|
+
// module produces the diff on the next scan.
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import * as crypto from 'node:crypto';
|
|
26
|
+
import { execSync } from 'node:child_process';
|
|
27
|
+
|
|
28
|
+
const HISTORY_DIR = 'sbom-history';
|
|
29
|
+
|
|
30
|
+
function _historyDir(scanRoot) {
|
|
31
|
+
return path.join(scanRoot, '.agentic-security', HISTORY_DIR);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _gitHead(scanRoot) {
|
|
35
|
+
try {
|
|
36
|
+
return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _snapshotKey(component) {
|
|
41
|
+
return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist the current SBOM as a snapshot keyed by the current git HEAD.
|
|
46
|
+
* If no git, falls back to a content hash of the components list.
|
|
47
|
+
*/
|
|
48
|
+
export function persistSbom(scanRoot, components) {
|
|
49
|
+
const dir = _historyDir(scanRoot);
|
|
50
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
51
|
+
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
52
|
+
const snap = {
|
|
53
|
+
sha, ts: new Date().toISOString(),
|
|
54
|
+
componentCount: components.length,
|
|
55
|
+
components: components.map(c => ({
|
|
56
|
+
ecosystem: c.ecosystem, name: c.name, version: c.version,
|
|
57
|
+
purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
|
|
58
|
+
sha256: c.sha256 || c.integrity || null,
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
62
|
+
return snap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load the previous SBOM snapshot for diffing.
|
|
67
|
+
*/
|
|
68
|
+
export function loadPreviousSnapshot(scanRoot, currentSha) {
|
|
69
|
+
const dir = _historyDir(scanRoot);
|
|
70
|
+
if (!fs.existsSync(dir)) return null;
|
|
71
|
+
let snaps;
|
|
72
|
+
try { snaps = fs.readdirSync(dir); } catch { return null; }
|
|
73
|
+
snaps = snaps.filter(f => f.endsWith('.json') && f !== `${currentSha}.json`);
|
|
74
|
+
if (!snaps.length) return null;
|
|
75
|
+
// Sort by mtime descending; take the most recent.
|
|
76
|
+
snaps.sort((a, b) => fs.statSync(path.join(dir, b)).mtimeMs - fs.statSync(path.join(dir, a)).mtimeMs);
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(fs.readFileSync(path.join(dir, snaps[0]), 'utf8'));
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute the structured diff between two SBOMs and emit drift findings.
|
|
84
|
+
*/
|
|
85
|
+
export function diffSboms(previous, current) {
|
|
86
|
+
if (!previous || !current) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 } };
|
|
87
|
+
const findings = [];
|
|
88
|
+
const prevByKey = new Map();
|
|
89
|
+
for (const c of previous.components || []) prevByKey.set(_snapshotKey(c), c);
|
|
90
|
+
const curByKey = new Map();
|
|
91
|
+
for (const c of current.components || []) curByKey.set(_snapshotKey(c), c);
|
|
92
|
+
let added = 0, removed = 0, bumped = 0, substituted = 0;
|
|
93
|
+
|
|
94
|
+
// Added
|
|
95
|
+
for (const [key, cur] of curByKey) {
|
|
96
|
+
if (prevByKey.has(key)) continue;
|
|
97
|
+
added++;
|
|
98
|
+
findings.push({
|
|
99
|
+
family: 'dependency-drift', subfamily: 'dependency-added',
|
|
100
|
+
severity: 'medium', cwe: 'CWE-1357',
|
|
101
|
+
vuln: `Dependency added since previous release: ${cur.ecosystem}:${cur.name}@${cur.version}`,
|
|
102
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
103
|
+
drift: { kind: 'added', component: cur, sinceSha: previous.sha },
|
|
104
|
+
remediation: 'Confirm this addition appears in a reviewed PR. Unexplained additions are the standard supply-chain-attack pattern.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Removed
|
|
108
|
+
for (const [key, prev] of prevByKey) {
|
|
109
|
+
if (curByKey.has(key)) continue;
|
|
110
|
+
removed++;
|
|
111
|
+
findings.push({
|
|
112
|
+
family: 'dependency-drift', subfamily: 'dependency-removed',
|
|
113
|
+
severity: 'low', cwe: 'CWE-1357',
|
|
114
|
+
vuln: `Dependency removed since previous release: ${prev.ecosystem}:${prev.name}@${prev.version}`,
|
|
115
|
+
file: prev.purl || 'package-lock.json', line: 0,
|
|
116
|
+
drift: { kind: 'removed', component: prev, sinceSha: previous.sha },
|
|
117
|
+
remediation: 'Confirm the removal was intentional. Silent removal of a vulnerable dep is fine; silent removal of a fix-receiving dep means CVEs may re-introduce.',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Bumped
|
|
121
|
+
for (const [key, cur] of curByKey) {
|
|
122
|
+
const prev = prevByKey.get(key);
|
|
123
|
+
if (!prev) continue;
|
|
124
|
+
if (prev.version !== cur.version) {
|
|
125
|
+
bumped++;
|
|
126
|
+
const isMajor = _isMajorBump(prev.version, cur.version);
|
|
127
|
+
findings.push({
|
|
128
|
+
family: 'dependency-drift', subfamily: 'dependency-version-bumped',
|
|
129
|
+
severity: isMajor ? 'medium' : 'low',
|
|
130
|
+
cwe: 'CWE-1357',
|
|
131
|
+
vuln: `${cur.ecosystem}:${cur.name} bumped ${prev.version} → ${cur.version}${isMajor ? ' (MAJOR)' : ''}`,
|
|
132
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
133
|
+
drift: { kind: 'bumped', from: prev.version, to: cur.version, isMajor, sinceSha: previous.sha },
|
|
134
|
+
remediation: 'Major-version bumps are breaking-change candidates AND attack pivot points. Verify the changelog signals match what your dependency intended.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Substitution check: integrity / hash changed but version stayed the same
|
|
138
|
+
if (prev.sha256 && cur.sha256 && prev.sha256 !== cur.sha256 && prev.version === cur.version) {
|
|
139
|
+
substituted++;
|
|
140
|
+
findings.push({
|
|
141
|
+
family: 'dependency-drift', subfamily: 'dependency-substitution',
|
|
142
|
+
severity: 'critical', cwe: 'CWE-1357',
|
|
143
|
+
vuln: `Suspicious substitution: ${cur.ecosystem}:${cur.name}@${cur.version} hash changed without version change`,
|
|
144
|
+
file: cur.purl || 'package-lock.json', line: 0,
|
|
145
|
+
drift: { kind: 'substituted', component: cur, oldHash: prev.sha256, newHash: cur.sha256, sinceSha: previous.sha },
|
|
146
|
+
remediation: 'Same package + same version + DIFFERENT content hash = the registry served a different artifact under the same identity. Investigate immediately; rotate the lockfile via fresh install.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { findings, summary: { added, removed, bumped, substituted } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _isMajorBump(prev, cur) {
|
|
154
|
+
const pa = (prev || '').match(/^(\d+)/);
|
|
155
|
+
const pc = (cur || '').match(/^(\d+)/);
|
|
156
|
+
if (!pa || !pc) return false;
|
|
157
|
+
return parseInt(pa[1], 10) !== parseInt(pc[1], 10);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Convenience entry — run the full pipeline: persist current, diff
|
|
162
|
+
* against previous, return findings.
|
|
163
|
+
*/
|
|
164
|
+
export function runSbomDiff(scanRoot, components) {
|
|
165
|
+
const current = persistSbom(scanRoot, components);
|
|
166
|
+
const previous = loadPreviousSnapshot(scanRoot, current.sha);
|
|
167
|
+
if (!previous) return { findings: [], summary: { added: 0, removed: 0, bumped: 0, substituted: 0 }, first: true };
|
|
168
|
+
return diffSboms(previous, current);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const _internals = { _historyDir, _gitHead, _isMajorBump, _snapshotKey };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// SCA policy — declarative per-project rules for vulnerable_dep findings.
|
|
2
|
+
// Phase 4 / Item 7 of the SCA improvement plan.
|
|
3
|
+
//
|
|
4
|
+
// Reads .agentic-security/sca-policy.yml. Three classes of rule:
|
|
5
|
+
//
|
|
6
|
+
// accept-risk — per-CVE or per-package suppression with reason +
|
|
7
|
+
// optional expiry. Treated as "wont-fix" — the
|
|
8
|
+
// finding still appears but is marked suppressed.
|
|
9
|
+
// sla — per-severity / per-tier deadlines for remediation.
|
|
10
|
+
// Findings older than the SLA are escalated.
|
|
11
|
+
// major-version-freeze — refuse automated major-version bumps per
|
|
12
|
+
// ecosystem. /fix --sca consults this before
|
|
13
|
+
// calling apply_sca_upgrade on a breaking change.
|
|
14
|
+
//
|
|
15
|
+
// Policy file shape:
|
|
16
|
+
//
|
|
17
|
+
// accept-risk:
|
|
18
|
+
// - cve: CVE-2024-12345
|
|
19
|
+
// reason: "patched upstream; bundled vendor copy doesn't include affected code"
|
|
20
|
+
// expires: 2026-12-31
|
|
21
|
+
// - package: log4j-core
|
|
22
|
+
// version: 2.17.1
|
|
23
|
+
// reason: "we run on Java 21; JNDI lookup is disabled at runtime"
|
|
24
|
+
// expires: 2026-06-30
|
|
25
|
+
//
|
|
26
|
+
// sla:
|
|
27
|
+
// critical-kev: 7d
|
|
28
|
+
// critical: 30d
|
|
29
|
+
// high: 90d
|
|
30
|
+
// medium: 180d
|
|
31
|
+
//
|
|
32
|
+
// major-version-freeze:
|
|
33
|
+
// npm: [react, vue] # never auto-upgrade these across majors
|
|
34
|
+
// pypi: [django]
|
|
35
|
+
//
|
|
36
|
+
// Default policy (no file) is permissive: nothing is suppressed and no
|
|
37
|
+
// SLA is enforced. Users opt in by creating the file.
|
|
38
|
+
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
import * as yaml from 'js-yaml';
|
|
42
|
+
|
|
43
|
+
const DEFAULT_POLICY = {
|
|
44
|
+
acceptRisk: [],
|
|
45
|
+
sla: {},
|
|
46
|
+
majorVersionFreeze: {},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function loadScaPolicy(scanRoot) {
|
|
50
|
+
if (!scanRoot) return null;
|
|
51
|
+
for (const name of ['sca-policy.yml', 'sca-policy.yaml', 'sca-policy.json']) {
|
|
52
|
+
const p = path.join(scanRoot, '.agentic-security', name);
|
|
53
|
+
if (!fs.existsSync(p)) continue;
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
56
|
+
const doc = name.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
|
|
57
|
+
return _normalize(doc);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { _error: `Failed to parse ${p}: ${e.message}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _normalize(doc) {
|
|
66
|
+
return {
|
|
67
|
+
acceptRisk: Array.isArray(doc?.['accept-risk']) ? doc['accept-risk'].map(_normalizeAccept) : [],
|
|
68
|
+
sla: _normalizeSla(doc?.sla || {}),
|
|
69
|
+
majorVersionFreeze: _normalizeMajor(doc?.['major-version-freeze'] || {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _normalizeAccept(entry) {
|
|
74
|
+
return {
|
|
75
|
+
cve: entry.cve ? String(entry.cve).toUpperCase() : null,
|
|
76
|
+
package: entry.package ? String(entry.package).toLowerCase() : null,
|
|
77
|
+
version: entry.version ? String(entry.version) : null,
|
|
78
|
+
ecosystem: entry.ecosystem ? String(entry.ecosystem).toLowerCase() : null,
|
|
79
|
+
reason: entry.reason || '',
|
|
80
|
+
expires: entry.expires || null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _normalizeSla(sla) {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [k, v] of Object.entries(sla)) {
|
|
87
|
+
out[k.toLowerCase()] = _parseSlaDuration(v);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _parseSlaDuration(v) {
|
|
93
|
+
if (typeof v === 'number') return v * 86400_000; // bare number = days
|
|
94
|
+
const m = String(v).match(/^(\d+)([dwmy])$/i);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
const n = parseInt(m[1], 10);
|
|
97
|
+
const unit = m[2].toLowerCase();
|
|
98
|
+
const factor = { d: 86400_000, w: 7 * 86400_000, m: 30 * 86400_000, y: 365 * 86400_000 }[unit];
|
|
99
|
+
return factor ? n * factor : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _normalizeMajor(maj) {
|
|
103
|
+
const out = {};
|
|
104
|
+
for (const [eco, list] of Object.entries(maj)) {
|
|
105
|
+
if (Array.isArray(list)) out[eco.toLowerCase()] = list.map(s => String(s).toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check whether an accept-risk entry is currently active (not expired).
|
|
111
|
+
function _accepted(entry, today = new Date()) {
|
|
112
|
+
if (!entry.expires) return true;
|
|
113
|
+
const exp = new Date(entry.expires);
|
|
114
|
+
if (Number.isNaN(+exp)) return true; // unparseable: treat as still active
|
|
115
|
+
return exp >= today;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Match a finding against the accept-risk list. Returns the matching entry
|
|
119
|
+
// or null.
|
|
120
|
+
export function matchAcceptRisk(finding, policy, today = new Date()) {
|
|
121
|
+
if (!policy || !Array.isArray(policy.acceptRisk)) return null;
|
|
122
|
+
const cves = Array.isArray(finding.cveAliases) ? finding.cveAliases.map(c => String(c).toUpperCase()) : [];
|
|
123
|
+
if (finding.osvId) cves.push(String(finding.osvId).toUpperCase());
|
|
124
|
+
const pkgName = String(finding.name || '').toLowerCase();
|
|
125
|
+
for (const entry of policy.acceptRisk) {
|
|
126
|
+
if (!_accepted(entry, today)) continue;
|
|
127
|
+
if (entry.cve && cves.includes(entry.cve)) return entry;
|
|
128
|
+
if (entry.package && pkgName === entry.package) {
|
|
129
|
+
if (entry.version && finding.version && entry.version !== finding.version) continue;
|
|
130
|
+
if (entry.ecosystem && finding.ecosystem && entry.ecosystem !== finding.ecosystem) continue;
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Apply policy to a list of SCA findings. Marks accept-risk hits as
|
|
138
|
+
// suppressed; computes SLA deadlines; flags major-version-freeze packages
|
|
139
|
+
// so /fix --sca knows not to auto-upgrade them.
|
|
140
|
+
//
|
|
141
|
+
// Returns { suppressed: number, slaTagged: number, frozen: number }.
|
|
142
|
+
export function applyScaPolicy(findings, policy, scanTime = new Date()) {
|
|
143
|
+
const stats = { suppressed: 0, slaTagged: 0, frozen: 0 };
|
|
144
|
+
if (!policy || !Array.isArray(findings)) return stats;
|
|
145
|
+
for (const f of findings) {
|
|
146
|
+
if (!f || f.type !== 'vulnerable_dep') continue;
|
|
147
|
+
// Accept-risk suppression
|
|
148
|
+
const acceptance = matchAcceptRisk(f, policy, scanTime);
|
|
149
|
+
if (acceptance) {
|
|
150
|
+
f.suppressed = true;
|
|
151
|
+
f.suppressionReason = acceptance.reason || 'accepted-risk';
|
|
152
|
+
f.suppressionSource = 'sca-policy.yml';
|
|
153
|
+
f.suppressionExpires = acceptance.expires || null;
|
|
154
|
+
stats.suppressed++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// SLA tag: pick the narrowest applicable bucket.
|
|
158
|
+
const slaKey = (f.kev || f.kevListed) && f.severity === 'critical' ? 'critical-kev'
|
|
159
|
+
: f.severity;
|
|
160
|
+
if (policy.sla && policy.sla[slaKey]) {
|
|
161
|
+
const startMs = f.firstSeenAt ? Date.parse(f.firstSeenAt) : +scanTime;
|
|
162
|
+
const deadline = startMs + policy.sla[slaKey];
|
|
163
|
+
f.slaDeadline = new Date(deadline).toISOString();
|
|
164
|
+
f.slaOverdue = +scanTime > deadline;
|
|
165
|
+
stats.slaTagged++;
|
|
166
|
+
}
|
|
167
|
+
// Major-version freeze
|
|
168
|
+
if (policy.majorVersionFreeze
|
|
169
|
+
&& Array.isArray(policy.majorVersionFreeze[f.ecosystem])
|
|
170
|
+
&& policy.majorVersionFreeze[f.ecosystem].includes(String(f.name).toLowerCase())) {
|
|
171
|
+
f.majorVersionFrozen = true;
|
|
172
|
+
stats.frozen++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return stats;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Materialize a wont-fix triage decision into a new accept-risk entry.
|
|
179
|
+
// Called by the triage → suppression bridge (Phase 4 / Item 7 of the SCA
|
|
180
|
+
// plan): when a user marks a vulnerable_dep finding wont-fix, the policy
|
|
181
|
+
// file is updated so future scans automatically suppress it.
|
|
182
|
+
//
|
|
183
|
+
// If the policy file doesn't exist, one is created with safe defaults.
|
|
184
|
+
export function appendAcceptRiskFromTriage(scanRoot, finding, reason) {
|
|
185
|
+
if (!scanRoot || !finding) return { ok: false, reason: 'missing arguments' };
|
|
186
|
+
const dir = path.join(scanRoot, '.agentic-security');
|
|
187
|
+
const fp = path.join(dir, 'sca-policy.yml');
|
|
188
|
+
let policy = loadScaPolicy(scanRoot);
|
|
189
|
+
if (policy && policy._error) return { ok: false, reason: policy._error };
|
|
190
|
+
if (!policy) policy = { acceptRisk: [], sla: {}, majorVersionFreeze: {} };
|
|
191
|
+
// Defensive: even if a policy file existed, it may have been parsed into a
|
|
192
|
+
// value that shares references with DEFAULT_POLICY. Force a fresh array.
|
|
193
|
+
if (!Array.isArray(policy.acceptRisk)) policy.acceptRisk = [];
|
|
194
|
+
|
|
195
|
+
// De-dupe — don't add a second entry for the same CVE.
|
|
196
|
+
const cves = Array.isArray(finding.cveAliases) ? finding.cveAliases.map(c => String(c).toUpperCase()) : [];
|
|
197
|
+
if (finding.osvId) cves.push(String(finding.osvId).toUpperCase());
|
|
198
|
+
for (const entry of policy.acceptRisk) {
|
|
199
|
+
if (entry.cve && cves.includes(entry.cve)) return { ok: false, reason: 'CVE already in accept-risk list', cve: entry.cve };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const newEntry = {
|
|
203
|
+
cve: cves[0] || null,
|
|
204
|
+
package: finding.name ? String(finding.name).toLowerCase() : null,
|
|
205
|
+
version: finding.version || null,
|
|
206
|
+
ecosystem: finding.ecosystem || null,
|
|
207
|
+
reason: reason || `Marked wont-fix in triage on ${new Date().toISOString().slice(0, 10)}`,
|
|
208
|
+
expires: null,
|
|
209
|
+
};
|
|
210
|
+
policy.acceptRisk.push(newEntry);
|
|
211
|
+
|
|
212
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
213
|
+
const serialized = yaml.dump({
|
|
214
|
+
'accept-risk': policy.acceptRisk.map(e => {
|
|
215
|
+
const o = {};
|
|
216
|
+
if (e.cve) o.cve = e.cve;
|
|
217
|
+
if (e.package) o.package = e.package;
|
|
218
|
+
if (e.version) o.version = e.version;
|
|
219
|
+
if (e.ecosystem) o.ecosystem = e.ecosystem;
|
|
220
|
+
o.reason = e.reason;
|
|
221
|
+
if (e.expires) o.expires = e.expires;
|
|
222
|
+
return o;
|
|
223
|
+
}),
|
|
224
|
+
sla: policy.sla && Object.keys(policy.sla).length ? Object.fromEntries(Object.entries(policy.sla).map(([k, v]) => [k, _formatSlaDuration(v)])) : undefined,
|
|
225
|
+
'major-version-freeze': policy.majorVersionFreeze && Object.keys(policy.majorVersionFreeze).length ? policy.majorVersionFreeze : undefined,
|
|
226
|
+
});
|
|
227
|
+
fs.writeFileSync(fp, serialized);
|
|
228
|
+
return { ok: true, entry: newEntry, path: fp };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _formatSlaDuration(ms) {
|
|
232
|
+
if (typeof ms !== 'number') return ms;
|
|
233
|
+
const days = Math.round(ms / 86400_000);
|
|
234
|
+
return `${days}d`;
|
|
235
|
+
}
|