@clear-capabilities/agentic-security-scanner 0.78.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/bin/.agentic-security/findings.json +16 -16
- package/bin/.agentic-security/last-scan.json +16 -16
- package/bin/.agentic-security/last-scan.json.sig +1 -1
- package/bin/.agentic-security/scan-history.json +51 -0
- package/bin/.agentic-security/streak.json +5 -5
- package/bin/agentic-security.js +22 -7
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +53 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +95 -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 +29799 -7803
- package/src/.agentic-security/last-scan.json +29799 -7803
- package/src/.agentic-security/last-scan.json.sig +1 -1
- package/src/.agentic-security/scan-history.json +5119 -2611
- package/src/.agentic-security/streak.json +6 -6
- package/src/dataflow/.agentic-security/findings.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json +2879 -308
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -1
- package/src/dataflow/.agentic-security/scan-history.json +68 -520
- package/src/dataflow/.agentic-security/streak.json +6 -7
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/engine.js +52 -8
- 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 +890 -132
- package/src/integrations/index.js +2 -1
- package/src/ir/.agentic-security/findings.json +240 -6
- package/src/ir/.agentic-security/last-scan.json +240 -6
- package/src/ir/.agentic-security/last-scan.json.sig +1 -1
- package/src/ir/.agentic-security/scan-history.json +16 -594
- package/src/ir/.agentic-security/streak.json +8 -9
- package/src/ir/callgraph.js +27 -7
- 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/llm-validator/index.js +7 -5
- 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/audit.js +5 -0
- package/src/mcp/tools.js +90 -1
- package/src/posture/.agentic-security/findings.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json +16809 -4367
- package/src/posture/.agentic-security/last-scan.json.sig +1 -1
- package/src/posture/.agentic-security/scan-history.json +6689 -177
- package/src/posture/.agentic-security/streak.json +8 -7
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +3 -2
- 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/fix-history.js +8 -2
- 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/profile.js +4 -5
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- 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/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage.js +29 -6
- package/src/posture/validator-metrics.js +3 -6
- package/src/sast/.agentic-security/findings.json +996 -32
- package/src/sast/.agentic-security/last-scan.json +996 -32
- package/src/sast/.agentic-security/last-scan.json.sig +1 -1
- package/src/sast/.agentic-security/scan-history.json +565 -32
- package/src/sast/.agentic-security/streak.json +10 -8
- 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/db-taint.js +24 -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/rust.js +26 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json +6044 -171
- package/src/sca/.agentic-security/last-scan.json.sig +1 -1
- package/src/sca/.agentic-security/scan-history.json +83 -6
- package/src/sca/.agentic-security/streak.json +9 -9
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +146 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/sigstore-verify.js +215 -0
- package/src/sca/vendor-detect.js +53 -0
- package/src/report/.agentic-security/findings.json +0 -80
- package/src/report/.agentic-security/last-scan.json +0 -80
- package/src/report/.agentic-security/last-scan.json.sig +0 -1
- package/src/report/.agentic-security/scan-history.json +0 -35
- package/src/report/.agentic-security/streak.json +0 -22
|
@@ -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
|
+
}
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
9
10
|
|
|
10
|
-
const HISTORY_FILE = '.agentic-security/scan-history.json';
|
|
11
11
|
const MAX_HISTORY = 30; // rolling window
|
|
12
12
|
|
|
13
13
|
function _readHistory(scanRoot) {
|
|
14
|
-
const histPath =
|
|
14
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
15
15
|
try {
|
|
16
16
|
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
17
17
|
} catch {
|
|
@@ -20,11 +20,8 @@ function _readHistory(scanRoot) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function _writeHistory(scanRoot, history) {
|
|
23
|
-
const histPath =
|
|
24
|
-
|
|
25
|
-
fs.mkdirSync(path.dirname(histPath), { recursive: true });
|
|
26
|
-
fs.writeFileSync(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
27
|
-
} catch {}
|
|
23
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
24
|
+
safeWriteState(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
function _snapshotFromScan(scan, label) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Project-root resolver for .agentic-security/ state.
|
|
2
|
+
//
|
|
3
|
+
// BUG HISTORY: Previously, state-writing code used the pattern
|
|
4
|
+
// `path.join(scanRoot || process.cwd(), '.agentic-security', ...)`
|
|
5
|
+
// When `scanRoot` was null/undefined and the scanner was invoked from
|
|
6
|
+
// a subdirectory (e.g., migrations/, config/), this created
|
|
7
|
+
// `.agentic-security/` folders inside those subdirectories — breaking
|
|
8
|
+
// the user's build (one report: DB migration system saw the folder as
|
|
9
|
+
// a migration file). One user uninstalled the plugin entirely.
|
|
10
|
+
//
|
|
11
|
+
// FIX: All state writes go through this module. process.cwd() is NEVER
|
|
12
|
+
// trusted directly. We walk upward from cwd looking for project markers
|
|
13
|
+
// (.git, package.json, etc.) and write state there. A safety check
|
|
14
|
+
// refuses to write if no marker is found.
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const PROJECT_MARKERS = [
|
|
20
|
+
'.git',
|
|
21
|
+
'package.json',
|
|
22
|
+
'pyproject.toml',
|
|
23
|
+
'go.mod',
|
|
24
|
+
'Cargo.toml',
|
|
25
|
+
'pom.xml',
|
|
26
|
+
'build.gradle',
|
|
27
|
+
'composer.json',
|
|
28
|
+
'Gemfile',
|
|
29
|
+
'.agentic-security',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function _findProjectRoot(startDir) {
|
|
33
|
+
let dir = path.resolve(startDir);
|
|
34
|
+
const visited = new Set();
|
|
35
|
+
while (dir && !visited.has(dir)) {
|
|
36
|
+
visited.add(dir);
|
|
37
|
+
for (const m of PROJECT_MARKERS) {
|
|
38
|
+
try {
|
|
39
|
+
if (fs.existsSync(path.join(dir, m))) return dir;
|
|
40
|
+
} catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveProjectRoot(scanRoot) {
|
|
50
|
+
// Prefer caller-provided scanRoot when it points to an existing directory
|
|
51
|
+
if (scanRoot && typeof scanRoot === 'string') {
|
|
52
|
+
try {
|
|
53
|
+
const resolved = path.resolve(scanRoot);
|
|
54
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
} catch { /* fall through */ }
|
|
58
|
+
}
|
|
59
|
+
// Walk upward from cwd looking for project markers
|
|
60
|
+
const fromCwd = _findProjectRoot(process.cwd());
|
|
61
|
+
if (fromCwd) return fromCwd;
|
|
62
|
+
// No project markers found — return cwd but caller should check via isSafeStateDir
|
|
63
|
+
return process.cwd();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function stateDir(scanRoot) {
|
|
67
|
+
return path.join(resolveProjectRoot(scanRoot), '.agentic-security');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function statePath(scanRoot, ...parts) {
|
|
71
|
+
return path.join(stateDir(scanRoot), ...parts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Safety check: refuse to create .agentic-security/ unless the parent
|
|
75
|
+
// directory has at least one project marker. Prevents littering when
|
|
76
|
+
// resolution falls through to a non-project directory.
|
|
77
|
+
export function isSafeStateDir(dir) {
|
|
78
|
+
if (!dir) return false;
|
|
79
|
+
const parent = path.dirname(dir);
|
|
80
|
+
for (const m of PROJECT_MARKERS) {
|
|
81
|
+
if (m === '.agentic-security') continue; // would be circular
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(path.join(parent, m))) return true;
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Safe mkdir: only creates .agentic-security/ if the parent has a project marker.
|
|
90
|
+
// Returns the dir on success, null if refused. Logs a warning when refused.
|
|
91
|
+
export function ensureStateDir(scanRoot) {
|
|
92
|
+
const dir = stateDir(scanRoot);
|
|
93
|
+
if (!isSafeStateDir(dir)) {
|
|
94
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
95
|
+
process.stderr.write(`[agentic-security] refusing to create state dir at ${dir} — no project marker in parent\n`);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
return dir;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Safe write: only writes if isSafeStateDir(parent) returns true.
|
|
108
|
+
// Returns true on success, false if refused or errored.
|
|
109
|
+
export function safeWriteState(filePath, content) {
|
|
110
|
+
const dir = path.dirname(filePath);
|
|
111
|
+
if (!isSafeStateDir(dir)) {
|
|
112
|
+
if (process.env.AGENTIC_SECURITY_DEBUG === '1') {
|
|
113
|
+
process.stderr.write(`[agentic-security] refusing to write state file at ${filePath} — no project marker in parent of ${dir}\n`);
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(filePath, content);
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/posture/streak.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from 'node:fs';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
|
+
import { isSafeStateDir } from './state-dir.js';
|
|
14
15
|
|
|
15
16
|
function _streakPath(stateDir) {
|
|
16
17
|
return path.join(stateDir, 'streak.json');
|
|
@@ -115,6 +116,7 @@ function _computeAchievements(streak, scan) {
|
|
|
115
116
|
|
|
116
117
|
// Public — invoked by the CLI after every full scan.
|
|
117
118
|
export function recordScan(stateDir, scan) {
|
|
119
|
+
if (!isSafeStateDir(stateDir)) return null;
|
|
118
120
|
try { fs.mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
119
121
|
const prev = loadStreak(stateDir);
|
|
120
122
|
const today = _todayUTC();
|
|
@@ -182,6 +184,7 @@ export function recordScan(stateDir, scan) {
|
|
|
182
184
|
|
|
183
185
|
// Mark "launch check passed 10/10" — called from /security-launch-check
|
|
184
186
|
export function markLaunchCheckPassed(stateDir) {
|
|
187
|
+
if (!isSafeStateDir(stateDir)) return null;
|
|
185
188
|
const prev = loadStreak(stateDir);
|
|
186
189
|
const next = { ...prev, launchCheckPassedAt: new Date().toISOString() };
|
|
187
190
|
next.achievements = _computeAchievements(next, { findings: [] });
|