@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. 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 the shell. If the project is not a
16
- // git repository (no `.git` at root), we return `{ available: false }`.
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 { execSync } from 'node:child_process';
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 = execSync(
38
- `git log --pretty=format:%H%x1f%an%x1f%aI%x1f%s --max-count=${limit} -- "${file}"`,
39
- { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
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
- return execSync(`git show ${sha}:"${file}"`, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
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 bucket = bucketOf(r, { assessable });
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 = (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.`;
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
- L.push('> This document organizes scanner evidence; it does not certify anything.');
232
- L.push('> A licensed assessor is responsible for the attestation.');
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 };