@clear-capabilities/agentic-security-scanner 0.142.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +399 -0
- package/bin/agentic-security.js +530 -54
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +108 -17
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +165 -52
- package/dist/526.index.js +108 -17
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +1 -1
- package/dist/730.index.js +311 -0
- package/dist/736.index.js +301 -0
- package/dist/824.index.js +7 -0
- package/dist/905.index.js +88 -22
- package/dist/920.index.js +491 -0
- package/dist/970.index.js +109 -0
- package/dist/agentic-security.mjs +13 -13
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/calibration-seed.json +2 -0
- package/package.json +19 -11
- package/src/dataflow/index.js +18 -0
- package/src/dataflow/privacy-catalog.js +290 -0
- package/src/dataflow/privacy-deep-walker.js +515 -0
- package/src/dataflow/privacy-governance.js +126 -0
- package/src/dataflow/privacy-inventory.js +154 -0
- package/src/dataflow/privacy-sink-policy.js +125 -0
- package/src/dataflow/privacy-taint.js +115 -54
- package/src/dataflow/privacy-taxonomy.js +233 -0
- package/src/discovery/disprove.js +7 -3
- package/src/discovery/hunter.js +9 -5
- package/src/discovery/index.js +2 -2
- package/src/discovery/llm-invoke.js +69 -13
- package/src/egress/audit.js +147 -0
- package/src/egress/policy.js +313 -0
- package/src/egress/redact.js +180 -0
- package/src/engine.js +575 -288
- package/src/fix/apply-fix-service.js +403 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/mcp/tools.js +157 -50
- package/src/pipeline/analyzer-supervisor.js +93 -0
- package/src/pipeline/analyzer-worker.js +26 -0
- package/src/pipeline/annotator-runner.js +33 -0
- package/src/pipeline/assurance-mode.js +91 -0
- package/src/pipeline/cascade-worker-pool.js +172 -0
- package/src/pipeline/cascade-worker.js +43 -0
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/detector-runner.js +51 -0
- package/src/pipeline/enrichment-completion.js +58 -0
- package/src/pipeline/evidence-provenance.js +91 -0
- package/src/pipeline/finding-schema.js +101 -0
- package/src/pipeline/legacy-compat.js +101 -0
- package/src/pipeline/producer-collector.js +48 -0
- package/src/pipeline/producer-registry.js +112 -0
- package/src/pipeline/scan-health.js +144 -0
- package/src/posture/CLAUDE.md +2 -0
- package/src/posture/accuracy-scorecard.js +96 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +217 -0
- package/src/posture/auditor-walkthrough.js +70 -8
- package/src/posture/calibration-feedback.js +201 -0
- package/src/posture/calibration-seed.json +2 -0
- package/src/posture/calibration.js +25 -0
- package/src/posture/compliance-evidence-signing.js +131 -0
- package/src/posture/compliance-policy.js +314 -17
- package/src/posture/custom-rules.js +36 -0
- package/src/posture/deterministic.js +8 -1
- package/src/posture/encryption-provider.js +205 -0
- package/src/posture/evidence-grade-wording.js +71 -0
- package/src/posture/fix-history.js +113 -19
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +56 -7
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +90 -0
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/privacy-framework.js +33 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/state-dir.js +36 -1
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +83 -18
- package/src/report/oscal.js +635 -0
- package/src/sast/cpp.js +3 -14
- package/src/sca/llm-function-extract.js +6 -0
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { loadFramework, evaluateFramework } from './auditor-walkthrough.js';
|
|
38
38
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
39
|
+
import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
|
|
39
40
|
|
|
40
41
|
export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
|
|
41
42
|
|
|
@@ -118,6 +119,24 @@ function severityFor(controlId) {
|
|
|
118
119
|
* Returns `{ frameworkId, controls[], summary, findings[] }`. Never throws:
|
|
119
120
|
* posture modules degrade to a null result rather than failing a scan.
|
|
120
121
|
*/
|
|
122
|
+
// FR-405 (assurance-hardening PRD): controls whose ENTIRE mapping depends on
|
|
123
|
+
// privacy-taint's signal — a "clean" (present) result from a NON-IR-backed
|
|
124
|
+
// run (deep mode off, the default path) is not real evidence for these
|
|
125
|
+
// specifically, even when the scan otherwise examined real files and is
|
|
126
|
+
// `assessable` for every other control. Deliberately conservative: a control
|
|
127
|
+
// with an ADDITIONAL, independent mapping (e.g. CT.DP-P1's `family:data-
|
|
128
|
+
// exposure` alongside `family:pii-exposure`) is left alone, because that
|
|
129
|
+
// other signal may still be real and this module cannot attribute which
|
|
130
|
+
// specific mapping produced a "present" verdict. Confirmed by direct
|
|
131
|
+
// execution against this exact framework file that CT.DP-P4/CT.DP-P5 (both
|
|
132
|
+
// codeTestable:'yes', both mapped ONLY to `module:privacy-taint`) currently
|
|
133
|
+
// read `satisfied` from a run with zero real IR-backed analysis — see
|
|
134
|
+
// decisions.md for the reproduction.
|
|
135
|
+
function _isPurelyPrivacyTaintDependent(control) {
|
|
136
|
+
const mapsTo = Array.isArray(control?.mapsTo) ? control.mapsTo : [];
|
|
137
|
+
return mapsTo.length > 0 && mapsTo.every(m => m === 'module:privacy-taint' || m === 'family:pii-exposure');
|
|
138
|
+
}
|
|
139
|
+
|
|
121
140
|
export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
122
141
|
const fw = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID);
|
|
123
142
|
if (!fw) return null;
|
|
@@ -131,6 +150,9 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
131
150
|
const assessable = filesScanned > 0
|
|
132
151
|
|| (Array.isArray(scan?.findings) && scan.findings.length > 0)
|
|
133
152
|
|| (Array.isArray(scan?.components) && scan.components.length > 0);
|
|
153
|
+
// `null` (never ran) and `false` (ran but degraded) are both "not real
|
|
154
|
+
// IR-backed evidence" for the gate below; only `true` clears it.
|
|
155
|
+
const privacyIrBacked = scan?.privacyIrBacked === true;
|
|
134
156
|
|
|
135
157
|
const controls = [];
|
|
136
158
|
const findings = [];
|
|
@@ -138,7 +160,9 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
138
160
|
|
|
139
161
|
for (const r of evaluation) {
|
|
140
162
|
const c = r.control || {};
|
|
141
|
-
const
|
|
163
|
+
const privacyTaintGated = !privacyIrBacked && _isPurelyPrivacyTaintDependent(c);
|
|
164
|
+
const controlAssessable = assessable && !privacyTaintGated;
|
|
165
|
+
const bucket = bucketOf(r, { assessable: controlAssessable });
|
|
142
166
|
summary[bucket] += 1;
|
|
143
167
|
summary.total += 1;
|
|
144
168
|
|
|
@@ -153,9 +177,11 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
153
177
|
};
|
|
154
178
|
if (bucket === 'engine-gap') {
|
|
155
179
|
// Named, not counted as a pass. See the header.
|
|
156
|
-
row.disclosure =
|
|
157
|
-
? 'This control
|
|
158
|
-
:
|
|
180
|
+
row.disclosure = privacyTaintGated
|
|
181
|
+
? 'This control depends entirely on privacy-taint\'s signal, which ran without a real IR this scan (deep mode off, or an unsupported language) — a "no findings" result in that mode is not real evidence. NOT assessed.'
|
|
182
|
+
: (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
|
|
183
|
+
? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
|
|
184
|
+
: `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
|
|
159
185
|
}
|
|
160
186
|
if (bucket === 'manual') {
|
|
161
187
|
row.disclosure = 'NIST rates this control not code-testable — it is a governance, policy, or process control and is outside any scanner\'s reach.';
|
|
@@ -228,8 +254,9 @@ function renderPrivacyMarkdown(result) {
|
|
|
228
254
|
L.push(`| Satisfied | ${result.summary.satisfied} | Mapped, and the signal is clean |`);
|
|
229
255
|
L.push('');
|
|
230
256
|
L.push('> A control in *Not assessed* or *Manual* is not evidence of compliance.');
|
|
231
|
-
|
|
232
|
-
|
|
257
|
+
// FR-507: sourced from evidence-grade-wording.js so this artifact stays in
|
|
258
|
+
// sync with every other compliance-adjacent disclaimer in the codebase.
|
|
259
|
+
L.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
233
260
|
L.push('');
|
|
234
261
|
for (const bucket of BUCKETS) {
|
|
235
262
|
const rows = result.controls.filter(c => c.bucket === bucket);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// FR-907 (assurance-hardening PRD): "Add longitudinal production feedback
|
|
2
|
+
// measurement | Metrics separate user suppression, accepted risk, invalid
|
|
3
|
+
// finding, fixed finding, and verification outcome."
|
|
4
|
+
//
|
|
5
|
+
// Read-only aggregation over 5 ALREADY-BUILT, separate mechanisms — this
|
|
6
|
+
// module invents no new storage of its own, only a unified view:
|
|
7
|
+
//
|
|
8
|
+
// user-suppression -> triage-memory.jsonl (decision:'wont-fix'),
|
|
9
|
+
// accepted.json (vibecoder soft-accept, dated),
|
|
10
|
+
// suppressions.yml (pro exception, undated)
|
|
11
|
+
// invalid-finding -> triage-memory.jsonl (decision:'false-positive')
|
|
12
|
+
// accepted-risk -> sca-policy.yml's accept-risk[] (undated snapshot
|
|
13
|
+
// — the policy file has no per-entry creation
|
|
14
|
+
// timestamp, only an optional future `expires`)
|
|
15
|
+
// fixed-finding -> fix-history/log.json (dated via `appliedAt`,
|
|
16
|
+
// status-tagged: applied/pending/reverted/failed)
|
|
17
|
+
// verification-outcome -> fix-metrics.jsonl (dated via `at`, ok-tagged)
|
|
18
|
+
//
|
|
19
|
+
// "Longitudinal" means: every event that HAS a real timestamp is usable in
|
|
20
|
+
// a time-bucketed trend; every event that does NOT (accept-risk entries,
|
|
21
|
+
// pro suppressions — neither schema records when the entry was added) is
|
|
22
|
+
// still counted in its category total but reported separately as
|
|
23
|
+
// "undated" rather than silently smeared across the time window or
|
|
24
|
+
// silently dropped. Same disclosed-gap discipline this codebase already
|
|
25
|
+
// uses elsewhere (privacy-framework.js's engine-gap bucket, accuracy-
|
|
26
|
+
// scorecard.js's excluded-from-denominator entries) — a number without
|
|
27
|
+
// its caveat is not a measurement.
|
|
28
|
+
//
|
|
29
|
+
// Never throws (posture convention): each of the 5 reads is wrapped
|
|
30
|
+
// independently, so a missing or malformed source degrades only that ONE
|
|
31
|
+
// category to empty, never blocks the other four. Every underlying reader
|
|
32
|
+
// (loadSoftAccepted, loadProSuppressions, loadMemory, loadScaPolicy,
|
|
33
|
+
// readLog, loadFixAttempts) already degrades gracefully on its own; the
|
|
34
|
+
// wrapping here is defense in depth against a reader whose contract
|
|
35
|
+
// changes later, not a claim that today's readers can throw.
|
|
36
|
+
|
|
37
|
+
import { loadSoftAccepted, loadProSuppressions } from './suppressions.js';
|
|
38
|
+
import { loadMemory } from './triage-memory.js';
|
|
39
|
+
import { loadScaPolicy } from './sca-policy.js';
|
|
40
|
+
import { readLog } from './fix-history.js';
|
|
41
|
+
import { loadFixAttempts } from './fix-metrics.js';
|
|
42
|
+
|
|
43
|
+
const MS_PER_DAY = 86400000;
|
|
44
|
+
|
|
45
|
+
// The 5 categories named verbatim in FR-907's own acceptance criterion.
|
|
46
|
+
export const CATEGORIES = Object.freeze([
|
|
47
|
+
'user-suppression', 'accepted-risk', 'invalid-finding', 'fixed-finding', 'verification-outcome',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function _safe(fn) {
|
|
51
|
+
try { return fn(); } catch { return []; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One unified event per underlying record, tagged with which of the 5
|
|
56
|
+
* PRD-named categories it belongs to. `at` is an ISO timestamp or null
|
|
57
|
+
* when the source schema has no per-entry creation date. `raw` keeps the
|
|
58
|
+
* original record for drill-down — never re-derived from the unified
|
|
59
|
+
* shape, so nothing is lost in translation.
|
|
60
|
+
*/
|
|
61
|
+
export function collectFeedbackEvents(scanRoot) {
|
|
62
|
+
const events = [];
|
|
63
|
+
|
|
64
|
+
for (const e of _safe(() => loadSoftAccepted(scanRoot))) {
|
|
65
|
+
events.push({
|
|
66
|
+
category: 'user-suppression', source: 'suppressions.js:accepted.json',
|
|
67
|
+
at: e.accepted_at || null, findingId: e.id || null, file: e.file || null,
|
|
68
|
+
line: e.line ?? null, outcome: 'soft-accepted', raw: e,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
for (const e of _safe(() => loadProSuppressions(scanRoot))) {
|
|
72
|
+
events.push({
|
|
73
|
+
category: 'user-suppression', source: 'suppressions.js:suppressions.yml',
|
|
74
|
+
at: null, findingId: e.finding_id || null, file: e.file || null,
|
|
75
|
+
line: null, outcome: 'pro-exception', raw: e,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const e of _safe(() => loadMemory(scanRoot))) {
|
|
79
|
+
if (e.decision === 'wont-fix') {
|
|
80
|
+
events.push({
|
|
81
|
+
category: 'user-suppression', source: 'triage-memory.js',
|
|
82
|
+
at: e.at || null, findingId: e.id || null, file: e.file || null,
|
|
83
|
+
line: e.line ?? null, outcome: 'wont-fix', raw: e,
|
|
84
|
+
});
|
|
85
|
+
} else if (e.decision === 'false-positive') {
|
|
86
|
+
events.push({
|
|
87
|
+
category: 'invalid-finding', source: 'triage-memory.js',
|
|
88
|
+
at: e.at || null, findingId: e.id || null, file: e.file || null,
|
|
89
|
+
line: e.line ?? null, outcome: 'false-positive', raw: e,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const scaPolicy = _safe(() => loadScaPolicy(scanRoot));
|
|
94
|
+
const acceptRisk = scaPolicy && Array.isArray(scaPolicy.acceptRisk) ? scaPolicy.acceptRisk : [];
|
|
95
|
+
for (const e of acceptRisk) {
|
|
96
|
+
events.push({
|
|
97
|
+
category: 'accepted-risk', source: 'sca-policy.js',
|
|
98
|
+
at: null, findingId: e.cve || e.package || null, file: null,
|
|
99
|
+
line: null, outcome: 'accept-risk', raw: e,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const e of _safe(() => readLog(scanRoot))) {
|
|
103
|
+
events.push({
|
|
104
|
+
category: 'fixed-finding', source: 'fix-history.js',
|
|
105
|
+
at: e.appliedAt || null, findingId: e.findingId || e.stableId || null,
|
|
106
|
+
file: e.file || null, line: null, outcome: e.status || 'unknown', raw: e,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const e of _safe(() => loadFixAttempts(scanRoot))) {
|
|
110
|
+
events.push({
|
|
111
|
+
category: 'verification-outcome', source: 'fix-metrics.js',
|
|
112
|
+
at: e.at || null, findingId: e.stableId || null, file: null,
|
|
113
|
+
line: null, outcome: e.ok ? 'verified' : 'not-verified', raw: e,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return events;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Bucket events by day within a rolling `sinceDays`-day window — the same
|
|
122
|
+
* cutoff-window shape as posture/triage.js's own trend(). An event with no
|
|
123
|
+
* timestamp (or an unparseable one) cannot be placed on a time axis: it is
|
|
124
|
+
* counted once in `undated` per category rather than silently dropped or
|
|
125
|
+
* silently smeared into the window.
|
|
126
|
+
*/
|
|
127
|
+
export function summarizeFeedbackTrend(events, { sinceDays = 30, now = Date.now() } = {}) {
|
|
128
|
+
const cutoff = now - sinceDays * MS_PER_DAY;
|
|
129
|
+
const byCategory = {};
|
|
130
|
+
for (const cat of CATEGORIES) byCategory[cat] = { total: 0, inWindow: 0, undated: 0 };
|
|
131
|
+
|
|
132
|
+
const dayBuckets = new Map(); // 'YYYY-MM-DD' -> { category: count }
|
|
133
|
+
for (const ev of Array.isArray(events) ? events : []) {
|
|
134
|
+
if (!ev || !CATEGORIES.includes(ev.category)) continue;
|
|
135
|
+
byCategory[ev.category].total++;
|
|
136
|
+
const t = ev.at ? Date.parse(ev.at) : NaN;
|
|
137
|
+
if (!Number.isFinite(t)) { byCategory[ev.category].undated++; continue; }
|
|
138
|
+
if (t < cutoff) continue;
|
|
139
|
+
byCategory[ev.category].inWindow++;
|
|
140
|
+
const dayKey = new Date(t).toISOString().slice(0, 10);
|
|
141
|
+
if (!dayBuckets.has(dayKey)) dayBuckets.set(dayKey, {});
|
|
142
|
+
const bucket = dayBuckets.get(dayKey);
|
|
143
|
+
bucket[ev.category] = (bucket[ev.category] || 0) + 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const series = [...dayBuckets.entries()]
|
|
147
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
148
|
+
.map(([day, counts]) => ({ day, counts }));
|
|
149
|
+
return { sinceDays, byCategory, series };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Convenience wrapper: collect + summarize in one call — same pattern as
|
|
154
|
+
* fix-metrics.js's fixDurationReport(scanRoot).
|
|
155
|
+
*/
|
|
156
|
+
export function productionFeedbackReport(scanRoot, opts) {
|
|
157
|
+
const events = collectFeedbackEvents(scanRoot);
|
|
158
|
+
return { events, ...summarizeFeedbackTrend(events, opts) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One block of human-readable summary, or null when nothing was measured
|
|
163
|
+
* at all — same "null when nothing measured" contract as
|
|
164
|
+
* renderFixDurationSummary, so a caller can skip the section entirely
|
|
165
|
+
* rather than print an empty header.
|
|
166
|
+
*/
|
|
167
|
+
export function renderProductionFeedbackSummary(report) {
|
|
168
|
+
if (!report || !Array.isArray(report.events) || !report.events.length) return null;
|
|
169
|
+
const lines = [`Production feedback (last ${report.sinceDays}d):`];
|
|
170
|
+
for (const cat of CATEGORIES) {
|
|
171
|
+
const c = report.byCategory[cat];
|
|
172
|
+
if (!c || c.total === 0) continue;
|
|
173
|
+
const undatedNote = c.undated ? `, ${c.undated} undated` : '';
|
|
174
|
+
lines.push(` ${cat}: ${c.inWindow} in window / ${c.total} total${undatedNote}`);
|
|
175
|
+
}
|
|
176
|
+
return lines.length > 1 ? lines.join('\n') : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const _internals = { MS_PER_DAY };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Retention policy: default and maximum TTL by artifact class (assurance-
|
|
2
|
+
// hardening PRD FR-702).
|
|
3
|
+
//
|
|
4
|
+
// "Enforce default and maximum TTL by artifact class | Expired caches,
|
|
5
|
+
// scans, evidence, tickets, and backups are purged or archived according
|
|
6
|
+
// to policy."
|
|
7
|
+
//
|
|
8
|
+
// TWO NUMBERS PER CLASS, ON PURPOSE
|
|
9
|
+
// --------------------------------------------------------------------------
|
|
10
|
+
// `defaultDays` is what applies when nobody configures anything — a
|
|
11
|
+
// reasonable, disclosed starting point, not a regulatory citation (an
|
|
12
|
+
// operator with a real compliance regime should configure their own via
|
|
13
|
+
// the policy file below). `maxDays` is a CEILING an operator's own
|
|
14
|
+
// configuration cannot exceed: the literal "default AND maximum" wording
|
|
15
|
+
// asks for both a floor-free default and a hard cap, not just a knob. An
|
|
16
|
+
// operator who wants indefinite retention for `evidence` for their own
|
|
17
|
+
// audit reasons should say so in their own retention program — this
|
|
18
|
+
// module will not silently allow a TTL past its built-in ceiling for a
|
|
19
|
+
// class, because "TTL" than can be configured to "never" is not a TTL.
|
|
20
|
+
//
|
|
21
|
+
// PURGE, NOT ARCHIVE
|
|
22
|
+
// --------------------------------------------------------------------------
|
|
23
|
+
// The acceptance criterion allows either. This implements PURGE (delete)
|
|
24
|
+
// only — the simpler, safer, and more directly verifiable of the two.
|
|
25
|
+
// "Archive" implies a defined export format and destination, which is
|
|
26
|
+
// FR-706's own separate scope (manifest-based export); an operator who
|
|
27
|
+
// wants to archive before purging already has the tool for it once FR-706
|
|
28
|
+
// exists, or can back up `.agentic-security/` by their own means before
|
|
29
|
+
// running the enforcement command.
|
|
30
|
+
//
|
|
31
|
+
// WHICH ARTIFACTS THIS APPLIES TO
|
|
32
|
+
// --------------------------------------------------------------------------
|
|
33
|
+
// Only 'generated' artifacts carrying a `retentionClass` in
|
|
34
|
+
// artifact-registry.js — deliberately a SUBSET of all generated artifacts
|
|
35
|
+
// (see that module's own header for which ones were left classless and
|
|
36
|
+
// why). An 'operator-config' artifact is NEVER touched by this module,
|
|
37
|
+
// regardless of age, matching FR-703's own "reset preserves operator-
|
|
38
|
+
// authored configuration" precedent.
|
|
39
|
+
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as yaml from '../util/yaml.js';
|
|
42
|
+
import { statePath, stateDir } from './state-dir.js';
|
|
43
|
+
import { listArtifactsWithRetentionClass } from './artifact-registry.js';
|
|
44
|
+
import { loadLegalHolds, isUnderHold } from './legal-hold.js';
|
|
45
|
+
|
|
46
|
+
const POLICY_FILE = 'retention-policy.yml';
|
|
47
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
// Engineering defaults, not a regulatory claim — see the header above.
|
|
50
|
+
export const RETENTION_DEFAULTS = {
|
|
51
|
+
cache: { defaultDays: 7, maxDays: 30 },
|
|
52
|
+
scan: { defaultDays: 90, maxDays: 365 },
|
|
53
|
+
evidence: { defaultDays: 365, maxDays: 1095 },
|
|
54
|
+
ticket: { defaultDays: 180, maxDays: 730 },
|
|
55
|
+
backup: { defaultDays: 30, maxDays: 180 },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Load an operator's `.agentic-security/retention-policy.yml`. Never
|
|
60
|
+
* throws — a missing or malformed file degrades to "no overrides," the
|
|
61
|
+
* same no-op-until-configured convention this repo uses for every other
|
|
62
|
+
* policy surface. Shape:
|
|
63
|
+
* cache: { defaultDays: 3 }
|
|
64
|
+
* evidence: { defaultDays: 730 }
|
|
65
|
+
* A class not mentioned, or a file not present at all, uses
|
|
66
|
+
* RETENTION_DEFAULTS unmodified.
|
|
67
|
+
*/
|
|
68
|
+
export function loadRetentionPolicy(scanRoot) {
|
|
69
|
+
if (!scanRoot) return null;
|
|
70
|
+
let fp;
|
|
71
|
+
try { fp = statePath(scanRoot, POLICY_FILE); } catch { return null; }
|
|
72
|
+
let raw;
|
|
73
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch { return null; }
|
|
74
|
+
try {
|
|
75
|
+
const doc = yaml.load(raw);
|
|
76
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
77
|
+
return doc;
|
|
78
|
+
} catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The TTL (in days) actually in effect for a class, after applying any
|
|
83
|
+
* operator override and clamping it to the class's own maxDays — an
|
|
84
|
+
* override CANNOT raise retention past the ceiling, only lower it (or
|
|
85
|
+
* leave it at the default). An unrecognised class (should not happen —
|
|
86
|
+
* only artifact-registry.js's own 5 named classes are ever passed in)
|
|
87
|
+
* degrades to null, meaning "not subject to a TTL."
|
|
88
|
+
*/
|
|
89
|
+
export function effectiveTtlDays(retentionClass, policy) {
|
|
90
|
+
const bounds = RETENTION_DEFAULTS[retentionClass];
|
|
91
|
+
if (!bounds) return null;
|
|
92
|
+
const override = policy?.[retentionClass]?.defaultDays;
|
|
93
|
+
if (typeof override === 'number' && Number.isFinite(override) && override >= 0) {
|
|
94
|
+
return Math.min(override, bounds.maxDays);
|
|
95
|
+
}
|
|
96
|
+
return bounds.defaultDays;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Which registered, retention-classed artifacts under `scanRoot`'s state
|
|
101
|
+
* dir are currently past their effective TTL. Never throws — a missing
|
|
102
|
+
* state dir or an artifact that does not exist on disk is simply absent
|
|
103
|
+
* from the result, not an error.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Array<{name: string, isDir: boolean, retentionClass: string,
|
|
106
|
+
* ageDays: number, ttlDays: number}>}
|
|
107
|
+
*/
|
|
108
|
+
export function findExpiredArtifacts(scanRoot, { now = Date.now() } = {}) {
|
|
109
|
+
const dir = stateDir(scanRoot);
|
|
110
|
+
let dirExists = true;
|
|
111
|
+
try { fs.accessSync(dir); } catch { dirExists = false; }
|
|
112
|
+
if (!dirExists) return [];
|
|
113
|
+
const policy = loadRetentionPolicy(scanRoot);
|
|
114
|
+
// FR-707: an artifact under an active legal hold is NEVER reported as
|
|
115
|
+
// expired, regardless of TTL — checked here (not just in cmdReset) so
|
|
116
|
+
// any future caller of this function inherits the same guarantee.
|
|
117
|
+
const holds = loadLegalHolds(scanRoot);
|
|
118
|
+
const expired = [];
|
|
119
|
+
for (const artifact of listArtifactsWithRetentionClass()) {
|
|
120
|
+
const ttlDays = effectiveTtlDays(artifact.retentionClass, policy);
|
|
121
|
+
if (ttlDays === null) continue;
|
|
122
|
+
if (isUnderHold(artifact.name, holds, now)) continue;
|
|
123
|
+
const p = `${dir}/${artifact.name}`;
|
|
124
|
+
let st;
|
|
125
|
+
try { st = fs.statSync(p); } catch { continue; } // not present — nothing to expire
|
|
126
|
+
const ageDays = (now - st.mtimeMs) / MS_PER_DAY;
|
|
127
|
+
if (ageDays > ttlDays) {
|
|
128
|
+
expired.push({ name: artifact.name, isDir: st.isDirectory(), retentionClass: artifact.retentionClass, ageDays, ttlDays });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return expired;
|
|
132
|
+
}
|