@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/bin/agentic-security.js +530 -54
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +83 -18
  93. package/src/report/oscal.js +635 -0
  94. package/src/sast/cpp.js +3 -14
  95. 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 bucket = bucketOf(r, { assessable });
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 = (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
157
- ? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
158
- : `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
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
- L.push('> This document organizes scanner evidence; it does not certify anything.');
232
- L.push('> A licensed assessor is responsible for the attestation.');
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
+ }