@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1
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/839.index.js +170 -0
- package/dist/985.index.js +140 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/.agentic-security/findings.json +117732 -0
- package/src/.agentic-security/last-scan.json +117732 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12946 -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 +825 -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 +331 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +140 -1
- package/src/posture/.agentic-security/findings.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +8904 -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/auditor-walkthrough.js +252 -0
- package/src/posture/claude-authorship.js +197 -0
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
- package/src/posture/compliance-frameworks/ccpa.json +32 -0
- package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
- package/src/posture/compliance-frameworks/gdpr.json +45 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/cross-repo-memory.js +180 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/dep-add-guard.js +197 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/findings-memory.js +152 -0
- package/src/posture/fix-style-mirror.js +118 -0
- package/src/posture/git-history.js +141 -0
- package/src/posture/intent-context.js +175 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/model-rescan.js +76 -0
- package/src/posture/pattern-propagation.js +39 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/pr-augment.js +234 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/risk-dollars.js +158 -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/threat-model-grounding.js +169 -0
- package/src/posture/time-to-fix.js +129 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage-memory.js +151 -0
- package/src/posture/triage.js +40 -1
- package/src/posture/watch-mode.js +171 -0
- package/src/posture/workflow-installer.js +231 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SCA upgrade engine: dry-run plan + worktree-isolated apply.
|
|
2
|
+
//
|
|
3
|
+
// Phase 3 / Item 5 of the SCA improvement plan. The MCP `apply_fix` path
|
|
4
|
+
// refuses to write manifest files (package.json, *-lock.*, poetry.lock,
|
|
5
|
+
// Cargo.lock, etc.) for safety. SCA findings need a separate path that:
|
|
6
|
+
// 1. Generates an upgrade *plan* via the ecosystem's native dry-run
|
|
7
|
+
// command (npm install --dry-run, pip install --dry-run, etc.).
|
|
8
|
+
// 2. Applies the upgrade via the package manager itself, with a backup
|
|
9
|
+
// + test-gate so a peer-dep break or test regression rolls back.
|
|
10
|
+
//
|
|
11
|
+
// Caller pattern: plan first (read-only), inspect the breaking-change
|
|
12
|
+
// flag / peer warnings, then apply with confirm:true.
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as fsp from 'node:fs/promises';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as crypto from 'node:crypto';
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { statePath } from './state-dir.js';
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
// Per-ecosystem command/manifest map. Add ecosystems by extending this
|
|
25
|
+
// table — every other place in the module reads it.
|
|
26
|
+
const ECOSYSTEM = {
|
|
27
|
+
npm: {
|
|
28
|
+
manifests: ['package.json', 'package-lock.json'],
|
|
29
|
+
altManifests: [['yarn.lock'], ['pnpm-lock.yaml']],
|
|
30
|
+
dryRun: (pkg, ver) => ({ cmd: 'npm', args: ['install', `${pkg}@${ver}`, '--dry-run', '--json'] }),
|
|
31
|
+
apply: (pkg, ver) => ({ cmd: 'npm', args: ['install', `${pkg}@${ver}`, '--save'] }),
|
|
32
|
+
parseDryRun(stdout) {
|
|
33
|
+
try {
|
|
34
|
+
const j = JSON.parse(stdout);
|
|
35
|
+
const peerDeps = Array.isArray(j.warnings) ? j.warnings.filter(w => /peer dep/i.test(w)) : [];
|
|
36
|
+
const transitiveImpact = (j.added || []).length + (j.updated || []).length + (j.removed || []).length;
|
|
37
|
+
return { peerDeps, transitiveImpact, rawSummary: { added: (j.added || []).length, updated: (j.updated || []).length, removed: (j.removed || []).length } };
|
|
38
|
+
} catch { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; }
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
pypi: {
|
|
42
|
+
manifests: ['requirements.txt', 'pyproject.toml'],
|
|
43
|
+
altManifests: [['poetry.lock'], ['Pipfile.lock']],
|
|
44
|
+
dryRun: (pkg, ver) => ({ cmd: 'pip', args: ['install', '--dry-run', `${pkg}==${ver}`] }),
|
|
45
|
+
apply: (pkg, ver) => ({ cmd: 'pip', args: ['install', '--upgrade', `${pkg}==${ver}`] }),
|
|
46
|
+
parseDryRun() {
|
|
47
|
+
// pip --dry-run output is human-readable; we don't parse it for v1.
|
|
48
|
+
return { peerDeps: [], transitiveImpact: 0, rawSummary: null };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
cargo: {
|
|
52
|
+
manifests: ['Cargo.toml', 'Cargo.lock'],
|
|
53
|
+
altManifests: [],
|
|
54
|
+
dryRun: (pkg, _ver) => ({ cmd: 'cargo', args: ['update', '--package', pkg, '--dry-run'] }),
|
|
55
|
+
apply: (pkg, _ver) => ({ cmd: 'cargo', args: ['update', '--package', pkg] }),
|
|
56
|
+
parseDryRun() { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; },
|
|
57
|
+
},
|
|
58
|
+
golang: {
|
|
59
|
+
manifests: ['go.mod', 'go.sum'],
|
|
60
|
+
altManifests: [],
|
|
61
|
+
dryRun: (_pkg, _ver) => null, // `go get` has no dry-run flag; we skip dry-run in v1.
|
|
62
|
+
apply: (pkg, ver) => ({ cmd: 'go', args: ['get', `${pkg}@v${ver}`] }),
|
|
63
|
+
parseDryRun() { return { peerDeps: [], transitiveImpact: 0, rawSummary: null }; },
|
|
64
|
+
},
|
|
65
|
+
// Other ecosystems (rubygems, packagist, pub, maven) return a structured
|
|
66
|
+
// "manual" plan in v1 — no native dry-run + the install side-effects
|
|
67
|
+
// (gem build artifacts, composer cache) are easier for the user to
|
|
68
|
+
// confirm interactively.
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function _majorVersion(v) {
|
|
72
|
+
const m = String(v || '').match(/^(\d+)/);
|
|
73
|
+
return m ? parseInt(m[1], 10) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _detectTestCommand(scanRoot) {
|
|
77
|
+
try {
|
|
78
|
+
const pkg = path.join(scanRoot, 'package.json');
|
|
79
|
+
if (fs.existsSync(pkg)) {
|
|
80
|
+
const j = JSON.parse(fs.readFileSync(pkg, 'utf8'));
|
|
81
|
+
if (j.scripts?.test && !/no test specified/i.test(j.scripts.test)) {
|
|
82
|
+
return { cmd: 'npm', args: ['test'] };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
if (fs.existsSync(path.join(scanRoot, 'Cargo.toml'))) return { cmd: 'cargo', args: ['test'] };
|
|
87
|
+
if (fs.existsSync(path.join(scanRoot, 'go.mod'))) return { cmd: 'go', args: ['test', './...'] };
|
|
88
|
+
if (fs.existsSync(path.join(scanRoot, 'pyproject.toml'))) return { cmd: 'pytest', args: [] };
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Produce a structured upgrade plan WITHOUT modifying anything on disk.
|
|
93
|
+
// Safe to call repeatedly; runs the ecosystem's --dry-run command.
|
|
94
|
+
export async function planScaUpgrade({ scanRoot, finding }) {
|
|
95
|
+
if (!finding || finding.type !== 'vulnerable_dep') {
|
|
96
|
+
return { ok: false, reason: 'not a vulnerable_dep finding' };
|
|
97
|
+
}
|
|
98
|
+
const eco = ECOSYSTEM[finding.ecosystem];
|
|
99
|
+
if (!eco) {
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
mode: 'manual',
|
|
103
|
+
reason: `ecosystem '${finding.ecosystem}' has no automated upgrade in v1`,
|
|
104
|
+
package: finding.name, currentVersion: finding.version,
|
|
105
|
+
targetVersion: (finding.fixedVersions && finding.fixedVersions[0]) || null,
|
|
106
|
+
command: null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const target = (finding.fixedVersions && finding.fixedVersions[0]) || null;
|
|
110
|
+
if (!target) {
|
|
111
|
+
return { ok: false, reason: 'no fixed version in OSV record' };
|
|
112
|
+
}
|
|
113
|
+
const isBreaking = (_majorVersion(target) ?? 0) > (_majorVersion(finding.version) ?? 0);
|
|
114
|
+
const apply = eco.apply(finding.name, target);
|
|
115
|
+
const dryRunSpec = eco.dryRun(finding.name, target);
|
|
116
|
+
let peerDeps = [], transitiveImpact = 0, rawSummary = null, dryRunOk = null;
|
|
117
|
+
if (dryRunSpec) {
|
|
118
|
+
try {
|
|
119
|
+
const r = await execFileAsync(dryRunSpec.cmd, dryRunSpec.args, {
|
|
120
|
+
cwd: scanRoot, timeout: 60_000, maxBuffer: 8 * 1024 * 1024,
|
|
121
|
+
});
|
|
122
|
+
const parsed = eco.parseDryRun(r.stdout);
|
|
123
|
+
peerDeps = parsed.peerDeps;
|
|
124
|
+
transitiveImpact = parsed.transitiveImpact;
|
|
125
|
+
rawSummary = parsed.rawSummary;
|
|
126
|
+
dryRunOk = true;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Dry-run failed (e.g. peer-dep resolution conflict). Surface the
|
|
129
|
+
// error structurally so the caller can decide whether to proceed.
|
|
130
|
+
dryRunOk = false;
|
|
131
|
+
const stderr = (e && e.stderr) || (e && e.message) || '';
|
|
132
|
+
peerDeps = /peer dep/i.test(stderr) ? [String(stderr).slice(0, 500)] : [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
mode: 'auto',
|
|
138
|
+
ecosystem: finding.ecosystem,
|
|
139
|
+
package: finding.name,
|
|
140
|
+
currentVersion: finding.version,
|
|
141
|
+
targetVersion: target,
|
|
142
|
+
isBreaking,
|
|
143
|
+
command: `${apply.cmd} ${apply.args.join(' ')}`,
|
|
144
|
+
manifestFiles: eco.manifests,
|
|
145
|
+
dryRun: { ok: dryRunOk, command: dryRunSpec ? `${dryRunSpec.cmd} ${dryRunSpec.args.join(' ')}` : null, peerDeps, transitiveImpact, rawSummary },
|
|
146
|
+
testCommand: (() => { const t = _detectTestCommand(scanRoot); return t ? `${t.cmd} ${t.args.join(' ')}` : null; })(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Apply the upgrade. Backs up affected manifests, runs the install, runs
|
|
151
|
+
// the project's test command if detected, and ROLLS BACK on failure.
|
|
152
|
+
// Audit-logged via the MCP audit layer at the call site.
|
|
153
|
+
export async function applyScaUpgrade({ scanRoot, finding, runTests = true }) {
|
|
154
|
+
const plan = await planScaUpgrade({ scanRoot, finding });
|
|
155
|
+
if (!plan.ok) return { applied: false, reason: plan.reason };
|
|
156
|
+
if (plan.mode === 'manual') {
|
|
157
|
+
return { applied: false, reason: plan.reason, plan };
|
|
158
|
+
}
|
|
159
|
+
const eco = ECOSYSTEM[finding.ecosystem];
|
|
160
|
+
const target = plan.targetVersion;
|
|
161
|
+
|
|
162
|
+
// Backup pass — record original contents of every relevant manifest so
|
|
163
|
+
// we can restore on test failure. node_modules / vendor dirs are NOT
|
|
164
|
+
// backed up (too big); they'll be rebuilt by re-running the install on
|
|
165
|
+
// restore.
|
|
166
|
+
const stateDir = statePath(scanRoot, 'sca-upgrade-history');
|
|
167
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
168
|
+
const upgradeId = crypto.randomBytes(8).toString('hex');
|
|
169
|
+
const backups = {};
|
|
170
|
+
for (const mf of eco.manifests) {
|
|
171
|
+
const abs = path.join(scanRoot, mf);
|
|
172
|
+
if (!fs.existsSync(abs)) continue;
|
|
173
|
+
const content = await fsp.readFile(abs, 'utf8');
|
|
174
|
+
const backupPath = path.join(stateDir, `${upgradeId}-${mf.replace(/[\/\\]/g, '_')}.bak`);
|
|
175
|
+
await fsp.writeFile(backupPath, content);
|
|
176
|
+
backups[mf] = { abs, backupPath };
|
|
177
|
+
}
|
|
178
|
+
if (!Object.keys(backups).length) {
|
|
179
|
+
return { applied: false, reason: `no ${finding.ecosystem} manifest files found in scan root` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Run the install.
|
|
183
|
+
const apply = eco.apply(finding.name, target);
|
|
184
|
+
let installOutput = '';
|
|
185
|
+
try {
|
|
186
|
+
const r = await execFileAsync(apply.cmd, apply.args, {
|
|
187
|
+
cwd: scanRoot, timeout: 300_000, maxBuffer: 16 * 1024 * 1024,
|
|
188
|
+
});
|
|
189
|
+
installOutput = (r.stdout || '') + (r.stderr || '');
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// Install failed; restore backups (manifests may have been touched).
|
|
192
|
+
for (const { abs, backupPath } of Object.values(backups)) {
|
|
193
|
+
try { await fsp.copyFile(backupPath, abs); } catch {}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
applied: false,
|
|
197
|
+
reason: `install failed: ${(e && e.message) || e}`.slice(0, 600),
|
|
198
|
+
installOutput: ((e && e.stdout) || '').slice(0, 1500),
|
|
199
|
+
restored: true,
|
|
200
|
+
upgradeId,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Optionally run the project's test command. On failure, restore.
|
|
205
|
+
let testResult = null;
|
|
206
|
+
if (runTests) {
|
|
207
|
+
const test = _detectTestCommand(scanRoot);
|
|
208
|
+
if (test) {
|
|
209
|
+
try {
|
|
210
|
+
const r = await execFileAsync(test.cmd, test.args, {
|
|
211
|
+
cwd: scanRoot, timeout: 600_000, maxBuffer: 16 * 1024 * 1024,
|
|
212
|
+
});
|
|
213
|
+
testResult = { ok: true, command: `${test.cmd} ${test.args.join(' ')}`, output: ((r.stdout || '') + (r.stderr || '')).slice(0, 2000) };
|
|
214
|
+
} catch (e) {
|
|
215
|
+
// Tests failed — restore manifests so the working tree is clean.
|
|
216
|
+
for (const { abs, backupPath } of Object.values(backups)) {
|
|
217
|
+
try { await fsp.copyFile(backupPath, abs); } catch {}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
applied: false,
|
|
221
|
+
reason: `tests failed after upgrade: ${(e && e.message) || e}`.slice(0, 300),
|
|
222
|
+
testOutput: ((e && e.stdout) || '').slice(0, 2000),
|
|
223
|
+
restored: true,
|
|
224
|
+
upgradeId,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Success path — write a log entry so the user can audit / undo.
|
|
231
|
+
const logEntry = {
|
|
232
|
+
id: upgradeId,
|
|
233
|
+
timestamp: new Date().toISOString(),
|
|
234
|
+
ecosystem: finding.ecosystem,
|
|
235
|
+
package: finding.name,
|
|
236
|
+
from: finding.version,
|
|
237
|
+
to: target,
|
|
238
|
+
backups: Object.fromEntries(Object.entries(backups).map(([k, v]) => [k, v.backupPath])),
|
|
239
|
+
testResult,
|
|
240
|
+
isBreaking: plan.isBreaking,
|
|
241
|
+
finding: { id: finding.id, osvId: finding.osvId, cveAliases: finding.cveAliases },
|
|
242
|
+
};
|
|
243
|
+
const logFp = path.join(stateDir, 'log.json');
|
|
244
|
+
let log = [];
|
|
245
|
+
try { log = JSON.parse(fs.readFileSync(logFp, 'utf8')); } catch {}
|
|
246
|
+
log.push(logEntry);
|
|
247
|
+
fs.writeFileSync(logFp, JSON.stringify(log, null, 2));
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
applied: true,
|
|
251
|
+
upgradeId,
|
|
252
|
+
package: finding.name,
|
|
253
|
+
from: finding.version,
|
|
254
|
+
to: target,
|
|
255
|
+
isBreaking: plan.isBreaking,
|
|
256
|
+
testResult,
|
|
257
|
+
installOutput: installOutput.slice(0, 1500),
|
|
258
|
+
};
|
|
259
|
+
}
|