@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.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 +555 -0
- package/bin/agentic-security.js +770 -49
- package/dist/1.index.js +223 -0
- package/dist/113.index.js +117 -18
- package/dist/144.index.js +163 -0
- package/dist/178.index.js +24 -6
- package/dist/238.index.js +3 -2
- package/dist/265.index.js +191 -0
- package/dist/271.index.js +165 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +187 -52
- package/dist/444.index.js +11 -2
- package/dist/449.index.js +76 -12
- package/dist/526.index.js +117 -18
- package/dist/552.index.js +97 -0
- package/dist/637.index.js +27 -5
- 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 +173 -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 +23 -9
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +1 -1
- 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 +1048 -302
- package/src/fix/apply-fix-service.js +404 -0
- package/src/fix/approver-registry.js +157 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +1 -1
- package/src/llm-validator/index.js +86 -9
- package/src/llm-validator/model-status.js +66 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/tools.js +177 -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 +154 -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 +108 -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 +123 -0
- package/src/posture/accuracy-scorecard.js +156 -1
- package/src/posture/adversary-agent.js +15 -3
- package/src/posture/artifact-registry.js +241 -0
- package/src/posture/auditor-walkthrough.js +186 -21
- 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 +324 -17
- package/src/posture/cross-repo-memory.js +7 -2
- 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 +137 -20
- package/src/posture/fix-honesty-gate.js +47 -6
- package/src/posture/fix-verify.js +65 -8
- package/src/posture/fleet.js +0 -0
- package/src/posture/flow-narration.js +7 -2
- package/src/posture/git-history.js +13 -5
- package/src/posture/legal-hold.js +140 -0
- package/src/posture/llm-redteam.js +10 -1
- package/src/posture/material-change.js +111 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/policy-bundle.js +274 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +47 -6
- package/src/posture/production-feedback.js +179 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/retention-policy.js +132 -0
- package/src/posture/risk-dollars.js +216 -26
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/scan-checkpoint.js +176 -31
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +64 -5
- package/src/posture/state-lifecycle-report.js +77 -0
- package/src/posture/suppressions.js +59 -3
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/privacy/ir-adapter.js +380 -0
- package/src/report/index.js +248 -5
- package/src/report/oscal.js +7 -2
- package/src/runScan.js +34 -5
- package/src/sast/cpp.js +3 -14
- package/src/sast/rate-limit.js +33 -3
- package/src/sca/llm-function-extract.js +6 -0
- package/src/util/git-hardening.js +128 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Signed, portable policy bundles (assurance-hardening PRD FR-1001).
|
|
2
|
+
//
|
|
3
|
+
// "Support signed portable policy bundles with organization, repository, and
|
|
4
|
+
// environment inheritance" | "Tampered or expired policy is rejected;
|
|
5
|
+
// effective policy is explainable."
|
|
6
|
+
//
|
|
7
|
+
// WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
|
|
8
|
+
// -------------------------------------------------------------------------
|
|
9
|
+
// A policy bundle is authored by one party (typically a central security
|
|
10
|
+
// team, "the organization") and distributed to many repositories that never
|
|
11
|
+
// had the signing key. integrity.js's per-install symmetric HMAC is
|
|
12
|
+
// tamper-evidence for the SAME install that wrote it — a different install
|
|
13
|
+
// verifying a bundle it didn't sign is exactly the case that scheme cannot
|
|
14
|
+
// serve. evidence-bundle.js already solved this for findings: Ed25519,
|
|
15
|
+
// verify with only the public key. This module reuses that primitive
|
|
16
|
+
// directly (canonicalisation shape, allowlist-signed-fields discipline,
|
|
17
|
+
// exclusive-create key generation) rather than inventing a third signing
|
|
18
|
+
// mechanism, per this session's own established "survey before building a
|
|
19
|
+
// parallel primitive" practice (see D-0023/D-0025 for the same discipline
|
|
20
|
+
// applied to identity and exception mechanisms respectively).
|
|
21
|
+
//
|
|
22
|
+
// EA-03 LESSON, APPLIED HERE FROM THE START
|
|
23
|
+
// -------------------------------------------------------------------------
|
|
24
|
+
// evidence-bundle.js's own history (see its EA-03 comment) is the reason
|
|
25
|
+
// `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
|
|
26
|
+
// REJECTS any top-level key outside it: a bundle with a field stapled on
|
|
27
|
+
// after signing must fail verification, not silently pass with the addition
|
|
28
|
+
// unverified. Getting this right on the first pass — rather than discovering
|
|
29
|
+
// the gap the way evidence-bundle.js did — is the entire point of writing it
|
|
30
|
+
// down here.
|
|
31
|
+
//
|
|
32
|
+
// SCOPE — WHAT THIS MODULE DOES NOT DO
|
|
33
|
+
// -------------------------------------------------------------------------
|
|
34
|
+
// It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
|
|
35
|
+
// keys a bundle carries (egress rules, severity floors, approved providers,
|
|
36
|
+
// anything else this repo already treats as policy) are merged the same way.
|
|
37
|
+
// It does not implement deep/recursive merging — inheritance is shallow,
|
|
38
|
+
// top-level-key override (organization -> repository -> environment, most
|
|
39
|
+
// specific wins per key), matching how every other flat policy config file
|
|
40
|
+
// in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
|
|
41
|
+
// already shaped. A future consumer needing nested-key inheritance is new,
|
|
42
|
+
// separate scope, not assumed here.
|
|
43
|
+
|
|
44
|
+
import * as fs from 'node:fs';
|
|
45
|
+
import * as os from 'node:os';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
import * as crypto from 'node:crypto';
|
|
48
|
+
import { ensureKeyPair as ensureKeyPairAt } from './evidence-bundle.js';
|
|
49
|
+
import { statePath } from './state-dir.js';
|
|
50
|
+
|
|
51
|
+
export const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
|
|
52
|
+
|
|
53
|
+
export const SCOPES = ['organization', 'repository', 'environment'];
|
|
54
|
+
// Inheritance order — later entries override earlier ones, per top-level key.
|
|
55
|
+
const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
|
|
56
|
+
|
|
57
|
+
function keyDir() {
|
|
58
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
59
|
+
return path.join(xdg, 'agentic-security', 'policy-bundles');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
|
|
63
|
+
* key — signing "this policy is authentic" and "this finding really came
|
|
64
|
+
* from a scan run by me" are different trust domains and must not share a
|
|
65
|
+
* key. Reuses evidence-bundle.js's hardened, race-safe generation logic
|
|
66
|
+
* directly (its exclusive-create collision handling is the kind of code
|
|
67
|
+
* worth NOT re-implementing) rather than duplicating it — the separation
|
|
68
|
+
* that matters is the DIRECTORY (`policy-bundles/`, isolated from
|
|
69
|
+
* evidence-bundle.js's own key dir), not the leaf filenames, which are
|
|
70
|
+
* `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
|
|
71
|
+
* evidence-bundle.js internal, not overridable here) — a readability wart,
|
|
72
|
+
* not a security one, since the directory is what prevents key reuse. */
|
|
73
|
+
export function ensurePolicyKeyPair(dir = keyDir()) {
|
|
74
|
+
return ensureKeyPairAt(dir);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deterministic JSON, keys sorted at every level — identical algorithm to
|
|
79
|
+
* evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
|
|
80
|
+
* a pure, three-line function, and importing it would couple this module's
|
|
81
|
+
* signature format to evidence-bundle.js's internals for no real benefit).
|
|
82
|
+
*/
|
|
83
|
+
function canonicalJson(value) {
|
|
84
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
|
|
85
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
86
|
+
const keys = Object.keys(value).sort();
|
|
87
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The complete set of fields a legitimately-built bundle carries, BEFORE
|
|
91
|
+
// signing. This is the signed allowlist AND (in verifyPolicyBundle) the
|
|
92
|
+
// complete set of keys a signed bundle is permitted to have alongside
|
|
93
|
+
// `signature` — see the EA-03 note above for why both matter.
|
|
94
|
+
const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
|
|
95
|
+
const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
|
|
96
|
+
|
|
97
|
+
export function canonicalPolicyBytes(bundle) {
|
|
98
|
+
const signed = {};
|
|
99
|
+
for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
|
|
100
|
+
return Buffer.from(canonicalJson(signed), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build an unsigned bundle. `policy` is caller-supplied and opaque — this
|
|
105
|
+
* module does not validate its shape, only its provenance and freshness.
|
|
106
|
+
*/
|
|
107
|
+
export function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
|
|
108
|
+
if (!SCOPES.includes(scope)) return null;
|
|
109
|
+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
|
|
110
|
+
return {
|
|
111
|
+
schema: POLICY_BUNDLE_SCHEMA,
|
|
112
|
+
scope,
|
|
113
|
+
policy,
|
|
114
|
+
issuedAt: issuedAt ?? new Date().toISOString(),
|
|
115
|
+
expiresAt: expiresAt ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sign a bundle. Returns a new object; the input is not mutated. */
|
|
120
|
+
export function signPolicyBundle(bundle, privateKeyPem) {
|
|
121
|
+
const sig = crypto.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
|
|
122
|
+
return {
|
|
123
|
+
...bundle,
|
|
124
|
+
signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Verify a bundle: signature authenticity AND freshness. Never throws — a
|
|
130
|
+
* malformed or tampered bundle from an untrusted source (a repo checking out
|
|
131
|
+
* whatever an org distributed) is an expected input, not exceptional.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{ok: boolean, reason: string|null}}
|
|
134
|
+
*/
|
|
135
|
+
export function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
|
|
136
|
+
if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
|
|
137
|
+
if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
|
|
138
|
+
if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
|
|
139
|
+
const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
|
|
140
|
+
if (unknownKeys.length) {
|
|
141
|
+
return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
|
|
142
|
+
}
|
|
143
|
+
const sig = bundle.signature;
|
|
144
|
+
if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
|
|
145
|
+
if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
|
|
146
|
+
if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
|
|
147
|
+
let sigOk = false;
|
|
148
|
+
try {
|
|
149
|
+
sigOk = crypto.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: false, reason: `verification error: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
|
|
154
|
+
if (bundle.expiresAt) {
|
|
155
|
+
const exp = Date.parse(bundle.expiresAt);
|
|
156
|
+
if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
|
|
157
|
+
if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, reason: null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Merge verified bundles into one effective policy, in inheritance order
|
|
164
|
+
* (organization -> repository -> environment; most specific wins per key).
|
|
165
|
+
* A tampered or expired bundle is EXCLUDED from the merge (rejected, not
|
|
166
|
+
* silently dropped — its scope and reason are reported so a rejection is
|
|
167
|
+
* visible, not just absent). `provenance[key]` names which scope's bundle
|
|
168
|
+
* last set that key — the "effective policy is explainable" half of the
|
|
169
|
+
* acceptance criterion.
|
|
170
|
+
*
|
|
171
|
+
* @param {Array<{scope: string, bundle: object}>} entries
|
|
172
|
+
* @param {string} publicKeyPem
|
|
173
|
+
* @returns {{effective: object, provenance: Record<string,string>,
|
|
174
|
+
* accepted: string[], rejected: Array<{scope:string, reason:string}>}}
|
|
175
|
+
*/
|
|
176
|
+
export function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
|
|
177
|
+
const byScope = new Map();
|
|
178
|
+
for (const e of entries || []) {
|
|
179
|
+
if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
|
|
180
|
+
}
|
|
181
|
+
const effective = {};
|
|
182
|
+
const provenance = {};
|
|
183
|
+
const accepted = [];
|
|
184
|
+
const rejected = [];
|
|
185
|
+
for (const scope of INHERITANCE_ORDER) {
|
|
186
|
+
const bundle = byScope.get(scope);
|
|
187
|
+
if (!bundle) continue;
|
|
188
|
+
const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
|
|
189
|
+
if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
|
|
190
|
+
accepted.push(scope);
|
|
191
|
+
for (const [k, val] of Object.entries(bundle.policy)) {
|
|
192
|
+
effective[k] = val;
|
|
193
|
+
provenance[k] = scope;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { effective, provenance, accepted, rejected };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Load whichever policy bundle files exist under
|
|
201
|
+
* `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
|
|
202
|
+
* Degrades gracefully — a missing directory or missing/malformed individual
|
|
203
|
+
* file is simply absent from the result, never thrown. Read-first-in-try/
|
|
204
|
+
* catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
|
|
205
|
+
*/
|
|
206
|
+
export function loadPolicyBundles(scanRoot) {
|
|
207
|
+
if (!scanRoot) return [];
|
|
208
|
+
const entries = [];
|
|
209
|
+
for (const scope of SCOPES) {
|
|
210
|
+
let fp;
|
|
211
|
+
try { fp = statePath(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
|
|
212
|
+
let raw;
|
|
213
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
214
|
+
try {
|
|
215
|
+
const bundle = JSON.parse(raw);
|
|
216
|
+
if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
|
|
217
|
+
} catch { /* malformed — skip, do not throw */ }
|
|
218
|
+
}
|
|
219
|
+
return entries;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read the operator-supplied public key an org distributes alongside its
|
|
224
|
+
* bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
|
|
225
|
+
* null (not a throw) if absent or unreadable — resolution then rejects
|
|
226
|
+
* every bundle for lack of a key, same as any other "no public key
|
|
227
|
+
* supplied" case in verifyPolicyBundle.
|
|
228
|
+
*/
|
|
229
|
+
export function loadPolicyPublicKey(scanRoot) {
|
|
230
|
+
if (!scanRoot) return null;
|
|
231
|
+
let fp;
|
|
232
|
+
try { fp = statePath(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
|
|
233
|
+
try { return fs.readFileSync(fp, 'utf8'); } catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
|
|
238
|
+
* policy diverge from what the organization-scope bundle alone specifies —
|
|
239
|
+
* and is anything the repo is relying on actually being rejected (tampered
|
|
240
|
+
* or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
|
|
241
|
+
* risk findings, which is the exact distinction FR-1006's acceptance
|
|
242
|
+
* criterion asks fleet output to make.
|
|
243
|
+
*
|
|
244
|
+
* No bundles configured at all is a no-op (`null`), matching this session's
|
|
245
|
+
* established convention: drift can only be reported relative to a
|
|
246
|
+
* baseline, and a repo with no organization bundle has no baseline to drift
|
|
247
|
+
* from.
|
|
248
|
+
*
|
|
249
|
+
* @returns {{accepted: string[], rejected: Array<{scope,reason}>,
|
|
250
|
+
* overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
|
|
251
|
+
* | null}
|
|
252
|
+
*/
|
|
253
|
+
export function computePolicyDrift(scanRoot) {
|
|
254
|
+
const entries = loadPolicyBundles(scanRoot);
|
|
255
|
+
if (!entries.length) return null;
|
|
256
|
+
const publicKeyPem = loadPolicyPublicKey(scanRoot);
|
|
257
|
+
const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
|
|
258
|
+
|
|
259
|
+
const overrides = [];
|
|
260
|
+
const orgEntry = entries.find(e => e.scope === 'organization');
|
|
261
|
+
if (orgEntry && accepted.includes('organization')) {
|
|
262
|
+
for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
|
|
263
|
+
const overriddenBy = provenance[key];
|
|
264
|
+
if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
|
|
265
|
+
overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { accepted, rejected, overrides };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _deepEqual(a, b) {
|
|
273
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
274
|
+
}
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// introducingCommit: { sha, author, ts, message } | null,
|
|
13
13
|
// }
|
|
14
14
|
//
|
|
15
|
-
// We invoke `git log` and `git show` via
|
|
16
|
-
//
|
|
15
|
+
// We invoke `git log` and `git show` via `execFileSync` (an argv array, NOT
|
|
16
|
+
// a shell) against `root` — the SCANNED project's repository, not this
|
|
17
|
+
// project's own trusted checkout. If the project is not a git repository
|
|
18
|
+
// (no `.git` at root), we return `{ available: false }`.
|
|
17
19
|
//
|
|
18
20
|
// This is intentionally light: we do not re-run the SAST detector on every
|
|
19
21
|
// historical revision. We use a simple substring-presence probe — does the
|
|
@@ -21,10 +23,30 @@
|
|
|
21
23
|
// Sufficient for the common case and dramatically cheaper than full
|
|
22
24
|
// re-scanning. Customers who want forensic-grade archaeology can re-run the
|
|
23
25
|
// scanner against `git checkout`-ed historical revisions.
|
|
26
|
+
//
|
|
27
|
+
// Hardened + de-shelled per FR-PROV-024 / the second Finding Provenance PRD
|
|
28
|
+
// audit sweep (found missing here by a follow-up review that grepped for
|
|
29
|
+
// `child_process` usage beyond just `execFileSync('git'` call sites). Before
|
|
30
|
+
// this fix, `gitShow` built a SHELL command string
|
|
31
|
+
// (`` `git show ${sha}:"${file}"` ``) from `file`, which is a finding's
|
|
32
|
+
// repo-relative path — attacker-controlled in a hostile repo. Double quotes
|
|
33
|
+
// in `sh` do NOT suppress `$(...)`/backticks, so a repo containing a file
|
|
34
|
+
// literally named `a$(cmd).js` would execute `cmd` via command substitution
|
|
35
|
+
// the moment this ran. Switching to `execFileSync` with an argv array
|
|
36
|
+
// removes the shell entirely — there is no interpreter left to interpret
|
|
37
|
+
// `$(...)`, so this is not "quote it better," it closes the injection class
|
|
38
|
+
// outright. `sha` here always originates from THIS module's own
|
|
39
|
+
// `gitLogForFile` (`%H`, real git-produced hex), but is still validated
|
|
40
|
+
// against the same SHA_RE every other resolver in this codebase uses, on
|
|
41
|
+
// the same "never trust, always validate" convention as
|
|
42
|
+
// provenance/git-evidence.js.
|
|
24
43
|
|
|
25
|
-
import {
|
|
44
|
+
import { execFileSync } from 'node:child_process';
|
|
26
45
|
import * as fs from 'node:fs';
|
|
27
46
|
import * as path from 'node:path';
|
|
47
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
48
|
+
|
|
49
|
+
const SHA_RE = /^[0-9a-f]{4,40}$/i;
|
|
28
50
|
|
|
29
51
|
function isGitRepo(root) {
|
|
30
52
|
try {
|
|
@@ -34,9 +56,10 @@ function isGitRepo(root) {
|
|
|
34
56
|
|
|
35
57
|
function gitLogForFile(root, file, limit = 50) {
|
|
36
58
|
try {
|
|
37
|
-
const out =
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
const out = execFileSync(
|
|
60
|
+
'git',
|
|
61
|
+
hardenGitArgs(['log', '--pretty=format:%H%x1f%an%x1f%aI%x1f%s', `--max-count=${limit}`, '--', file]),
|
|
62
|
+
{ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() },
|
|
40
63
|
);
|
|
41
64
|
return out.split(/\n/).filter(Boolean).map(line => {
|
|
42
65
|
const [sha, author, ts, message] = line.split('\x1f');
|
|
@@ -46,8 +69,17 @@ function gitLogForFile(root, file, limit = 50) {
|
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
function gitShow(root, sha, file) {
|
|
72
|
+
if (typeof sha !== 'string' || !SHA_RE.test(sha)) return null;
|
|
49
73
|
try {
|
|
50
|
-
|
|
74
|
+
// `./` makes git resolve `file` relative to cwd (root) — same convention
|
|
75
|
+
// as provenance/git-evidence.js's getBlobAtCommit, and load-bearing for
|
|
76
|
+
// the same reason: it also keeps a `file` that happens to start with
|
|
77
|
+
// `-` from being parsed as a flag once concatenated onto `sha:`.
|
|
78
|
+
// `--no-textconv`: this is the blob-cat form of `show` (`<sha>:<path>`,
|
|
79
|
+
// not a diff) — VERIFIED not reachable via a hostile textconv driver in
|
|
80
|
+
// current git (same as git-evidence.js's getBlobAtCommit), kept for
|
|
81
|
+
// defense-in-depth/uniformity.
|
|
82
|
+
return execFileSync('git', hardenGitArgs(['show', '--no-textconv', `${sha}:./${file}`]), { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() });
|
|
51
83
|
} catch { return null; }
|
|
52
84
|
}
|
|
53
85
|
|
|
@@ -36,6 +36,8 @@
|
|
|
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';
|
|
40
|
+
import { PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
|
|
39
41
|
|
|
40
42
|
export const PRIVACY_FRAMEWORK_ID = 'nist-privacy-1-1';
|
|
41
43
|
|
|
@@ -118,6 +120,24 @@ function severityFor(controlId) {
|
|
|
118
120
|
* Returns `{ frameworkId, controls[], summary, findings[] }`. Never throws:
|
|
119
121
|
* posture modules degrade to a null result rather than failing a scan.
|
|
120
122
|
*/
|
|
123
|
+
// FR-405 (assurance-hardening PRD): controls whose ENTIRE mapping depends on
|
|
124
|
+
// privacy-taint's signal — a "clean" (present) result from a NON-IR-backed
|
|
125
|
+
// run (deep mode off, the default path) is not real evidence for these
|
|
126
|
+
// specifically, even when the scan otherwise examined real files and is
|
|
127
|
+
// `assessable` for every other control. Deliberately conservative: a control
|
|
128
|
+
// with an ADDITIONAL, independent mapping (e.g. CT.DP-P1's `family:data-
|
|
129
|
+
// exposure` alongside `family:pii-exposure`) is left alone, because that
|
|
130
|
+
// other signal may still be real and this module cannot attribute which
|
|
131
|
+
// specific mapping produced a "present" verdict. Confirmed by direct
|
|
132
|
+
// execution against this exact framework file that CT.DP-P4/CT.DP-P5 (both
|
|
133
|
+
// codeTestable:'yes', both mapped ONLY to `module:privacy-taint`) currently
|
|
134
|
+
// read `satisfied` from a run with zero real IR-backed analysis — see
|
|
135
|
+
// decisions.md for the reproduction.
|
|
136
|
+
function _isPurelyPrivacyTaintDependent(control) {
|
|
137
|
+
const mapsTo = Array.isArray(control?.mapsTo) ? control.mapsTo : [];
|
|
138
|
+
return mapsTo.length > 0 && mapsTo.every(m => m === 'module:privacy-taint' || m === 'family:pii-exposure');
|
|
139
|
+
}
|
|
140
|
+
|
|
121
141
|
export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
122
142
|
const fw = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID);
|
|
123
143
|
if (!fw) return null;
|
|
@@ -131,6 +151,9 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
131
151
|
const assessable = filesScanned > 0
|
|
132
152
|
|| (Array.isArray(scan?.findings) && scan.findings.length > 0)
|
|
133
153
|
|| (Array.isArray(scan?.components) && scan.components.length > 0);
|
|
154
|
+
// `null` (never ran) and `false` (ran but degraded) are both "not real
|
|
155
|
+
// IR-backed evidence" for the gate below; only `true` clears it.
|
|
156
|
+
const privacyIrBacked = scan?.privacyIrBacked === true;
|
|
134
157
|
|
|
135
158
|
const controls = [];
|
|
136
159
|
const findings = [];
|
|
@@ -138,7 +161,9 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
138
161
|
|
|
139
162
|
for (const r of evaluation) {
|
|
140
163
|
const c = r.control || {};
|
|
141
|
-
const
|
|
164
|
+
const privacyTaintGated = !privacyIrBacked && _isPurelyPrivacyTaintDependent(c);
|
|
165
|
+
const controlAssessable = assessable && !privacyTaintGated;
|
|
166
|
+
const bucket = bucketOf(r, { assessable: controlAssessable });
|
|
142
167
|
summary[bucket] += 1;
|
|
143
168
|
summary.total += 1;
|
|
144
169
|
|
|
@@ -153,9 +178,11 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
153
178
|
};
|
|
154
179
|
if (bucket === 'engine-gap') {
|
|
155
180
|
// Named, not counted as a pass. See the header.
|
|
156
|
-
row.disclosure =
|
|
157
|
-
? 'This control
|
|
158
|
-
:
|
|
181
|
+
row.disclosure = privacyTaintGated
|
|
182
|
+
? '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.'
|
|
183
|
+
: (Array.isArray(c.mapsTo) && c.mapsTo.length && !assessable)
|
|
184
|
+
? 'This control is mapped, but the scan examined no files — a clean signal from a run that read nothing is not evidence. NOT assessed.'
|
|
185
|
+
: `NIST rates this control code-testable (${row.codeTestable}), but this engine has no signal for it. It was NOT assessed.`;
|
|
159
186
|
}
|
|
160
187
|
if (bucket === 'manual') {
|
|
161
188
|
row.disclosure = 'NIST rates this control not code-testable — it is a governance, policy, or process control and is outside any scanner\'s reach.';
|
|
@@ -183,6 +210,19 @@ export function assessPrivacyFramework(scanRoot, scan, opts = {}) {
|
|
|
183
210
|
parser: 'COMPLIANCE',
|
|
184
211
|
family: 'privacy-compliance',
|
|
185
212
|
complianceControl: { framework: PRIVACY_FRAMEWORK_ID, id: c.id, codeTestable: c.codeTestable || 'no' },
|
|
213
|
+
controlRefs: r.controlRefs || [],
|
|
214
|
+
// PRD Section 8 REQUIRED DISCLAIMER. `derivedProvenance` carries an
|
|
215
|
+
// origin commit, author and confidence onto a COMPLIANCE claim, and
|
|
216
|
+
// `compliance --privacy --format json` prints this object straight to
|
|
217
|
+
// stdout -- a second human-facing surface for the same data the auditor
|
|
218
|
+
// walkthrough disclaims inline. Found by a review of the walkthrough
|
|
219
|
+
// fix, which had asserted renderWalkthrough was the only consumer. The
|
|
220
|
+
// disclaimer travels ON the record rather than beside it, because a
|
|
221
|
+
// JSON consumer can slice a single finding out of the array and the
|
|
222
|
+
// caveat has to survive that.
|
|
223
|
+
derivedProvenance: r.derivedProvenance
|
|
224
|
+
? { ...r.derivedProvenance, disclaimer: PROVENANCE_COMPLIANCE_DISCLAIMER }
|
|
225
|
+
: null,
|
|
186
226
|
});
|
|
187
227
|
}
|
|
188
228
|
|
|
@@ -228,8 +268,9 @@ function renderPrivacyMarkdown(result) {
|
|
|
228
268
|
L.push(`| Satisfied | ${result.summary.satisfied} | Mapped, and the signal is clean |`);
|
|
229
269
|
L.push('');
|
|
230
270
|
L.push('> A control in *Not assessed* or *Manual* is not evidence of compliance.');
|
|
231
|
-
|
|
232
|
-
|
|
271
|
+
// FR-507: sourced from evidence-grade-wording.js so this artifact stays in
|
|
272
|
+
// sync with every other compliance-adjacent disclaimer in the codebase.
|
|
273
|
+
L.push(`> ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
|
|
233
274
|
L.push('');
|
|
234
275
|
for (const bucket of BUCKETS) {
|
|
235
276
|
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 };
|