@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,144 @@
1
+ // Signed provenance evidence bundles (Finding Provenance PRD, M4 §4.1).
2
+ //
3
+ // Sibling to posture/evidence-bundle.js, not a generalization of it — that
4
+ // module's bundle shape (proofTier, taintPath, exploitability...) answers
5
+ // "is this finding real"; this one answers "who/when introduced it, how
6
+ // sure are we." Forcing one shape to cover both would leave half of every
7
+ // bundle null. Same four-function pattern (build/sign/verify + a schema
8
+ // string + a top-level-key allowlist), same Ed25519 key material — reused,
9
+ // not reimplemented.
10
+ //
11
+ // The allowlisted fields mirror provenance/coordinator.js's own
12
+ // computeDigest() material EXACTLY (stableId, findingOrigin.commit,
13
+ // branchIntroduction.commit, evidenceAttribution role:path:line:commit
14
+ // strings, method, confidence.reasons, limitations) plus repo/HEAD identity
15
+ // (not in computeDigest's material, since that digest never leaves the repo
16
+ // it was computed in, but a bundle does). Everything here is copied from
17
+ // what findingProvenance already computed. Nothing is inferred.
18
+ //
19
+ // canonicalJson vs canonicalBytes — resolved before writing this file
20
+ // -------------------------------------------------------------------
21
+ // evidence-bundle.js exports TWO canonicalisation helpers, not one, and they
22
+ // are not interchangeable. `canonicalJson(value)` is a pure, shape-agnostic
23
+ // deterministic serialiser (sorted keys at every level, order-preserving
24
+ // arrays) — safe to reuse for any bundle shape. `canonicalBytes(bundle)`
25
+ // is NOT generic: it hardcodes evidence-bundle.js's own six top-level keys
26
+ // (`schema, finding, evidence, engine, proves, doesNotProve`) when building
27
+ // the object it signs. Reusing `canonicalBytes` here would silently sign a
28
+ // filtered object missing this module's `repo` and `provenance` fields
29
+ // (and would read `bundle.evidence`, which a provenance bundle never has) —
30
+ // exactly the EA-03 failure mode the allowlist check below exists to catch,
31
+ // just introduced from the signing side instead of the verifying side. So
32
+ // this module imports the generic `canonicalJson` (as the brief's Step 1
33
+ // sketch already did) and signs `canonicalJson(bundle)` directly, since
34
+ // `buildProvenanceEvidenceBundle` never puts anything on the unsigned
35
+ // object beyond this module's own allowlisted fields.
36
+
37
+ import * as crypto from 'node:crypto';
38
+ import { ensureKeyPair, keyPaths, canonicalJson } from './evidence-bundle.js';
39
+
40
+ export const PROVENANCE_BUNDLE_SCHEMA = 'agentic-security/provenance-evidence@1';
41
+
42
+ const PROVES = 'This bundle\'s contents are exactly what was signed at attestation time.';
43
+ const DOES_NOT_PROVE = 'This bundle does NOT prove the origin commit is correctly identified — read confidence.level and limitations for that. It proves the RECORD is unmodified, not that the record is right.';
44
+
45
+ /**
46
+ * Build an unsigned provenance bundle from one finding's findingProvenance.
47
+ * Returns null for a finding with no findingProvenance at all (nothing to
48
+ * attest) — this is a caller error (attest a scan before its provenance
49
+ * pass ran), not a case to paper over with an empty bundle.
50
+ */
51
+ export function buildProvenanceEvidenceBundle(finding, { engineVersion, repoIdentity, head } = {}) {
52
+ if (!finding || typeof finding !== 'object') return null;
53
+ const fp = finding.findingProvenance;
54
+ if (!fp || typeof fp !== 'object') return null;
55
+ return {
56
+ schema: PROVENANCE_BUNDLE_SCHEMA,
57
+ finding: {
58
+ id: finding.id ?? null,
59
+ stableId: finding.stableId ?? null,
60
+ },
61
+ repo: {
62
+ identity: repoIdentity ?? null,
63
+ head: head ?? fp.analysisBasis?.head ?? null,
64
+ },
65
+ provenance: {
66
+ status: fp.status ?? null,
67
+ findingOrigin: fp.findingOrigin
68
+ ? {
69
+ commit: fp.findingOrigin.commit ?? null,
70
+ authorName: fp.findingOrigin.authorName ?? null,
71
+ authorDate: fp.findingOrigin.authorDate ?? null,
72
+ summary: fp.findingOrigin.summary ?? null,
73
+ }
74
+ : null,
75
+ branchIntroduction: fp.branchIntroduction
76
+ ? { commit: fp.branchIntroduction.commit ?? null, branch: fp.branchIntroduction.branch ?? null }
77
+ : null,
78
+ evidenceAttribution: (fp.evidenceAttribution || []).map((n) => ({
79
+ role: n.role ?? null, path: n.path ?? null, line: n.line ?? null, commit: n.commit ?? null,
80
+ })),
81
+ method: fp.method ?? null,
82
+ confidence: fp.confidence
83
+ ? { level: fp.confidence.level ?? null, score: fp.confidence.score ?? null, reasons: fp.confidence.reasons || [] }
84
+ : null,
85
+ limitations: fp.limitations || [],
86
+ // M4 §4.2 final-review fix: this module was written (Task 1) before
87
+ // the cross-repo lineage feature existed (Task 5), so a
88
+ // cross-repo-resolved origin's boundary-crossing marker was dropped
89
+ // from the signed bundle — a foreign repository's commit SHA and a
90
+ // real author name, with no MACHINE-READABLE signal that the origin
91
+ // crossed a repository boundary (only the prose in `limitations`
92
+ // said so, and a programmatic verifier does not read prose). Nested
93
+ // under `provenance` alongside the fields above, so it is part of
94
+ // what gets signed — never a new top-level key.
95
+ historyCoverage: { crossRepoLineage: fp.historyCoverage?.crossRepoLineage ?? false },
96
+ },
97
+ engine: { engineVersion: engineVersion ?? null },
98
+ proves: PROVES,
99
+ doesNotProve: DOES_NOT_PROVE,
100
+ };
101
+ }
102
+
103
+ export function signProvenanceEvidenceBundle(bundle, privateKeyPem) {
104
+ const sig = crypto.sign(null, Buffer.from(canonicalJson(bundle), 'utf8'), privateKeyPem);
105
+ return {
106
+ ...bundle,
107
+ signature: { algorithm: 'ed25519', canonicalisation: PROVENANCE_BUNDLE_SCHEMA, value: sig.toString('base64') },
108
+ };
109
+ }
110
+
111
+ const PROVENANCE_BUNDLE_TOP_LEVEL_KEYS = new Set([
112
+ 'schema', 'finding', 'repo', 'provenance', 'engine', 'proves', 'doesNotProve', 'signature',
113
+ ]);
114
+
115
+ /**
116
+ * Verify with a PUBLIC key only. Rejects any top-level key outside the
117
+ * allowlist BEFORE checking the signature — same EA-03 fix evidence-bundle.js
118
+ * carries: a signature only covers the bytes it was computed over, so an
119
+ * unknown key stapled on after signing would otherwise verify as authentic.
120
+ */
121
+ export function verifyProvenanceEvidenceBundle(bundle, publicKeyPem) {
122
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
123
+ if (bundle.schema !== PROVENANCE_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
124
+ const unknownKeys = Object.keys(bundle).filter((k) => !PROVENANCE_BUNDLE_TOP_LEVEL_KEYS.has(k));
125
+ if (unknownKeys.length) {
126
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
127
+ }
128
+ const sig = bundle.signature;
129
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
130
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
131
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
132
+ const { signature, ...unsigned } = bundle;
133
+ let ok = false;
134
+ try {
135
+ ok = crypto.verify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKeyPem, Buffer.from(sig.value, 'base64'));
136
+ } catch (e) {
137
+ return { ok: false, reason: `verification error: ${e.message}` };
138
+ }
139
+ return ok
140
+ ? { ok: true, reason: null }
141
+ : { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
142
+ }
143
+
144
+ export { ensureKeyPair, keyPaths };
@@ -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
+ }
@@ -9,7 +9,7 @@
9
9
  // Discount reachability tier (route-reachable > function-reachable
10
10
  // > unknown > unreachable)
11
11
  //
12
- // EV per finding = P × Impact × Discount × ConfidenceFloor
12
+ // EV per finding = P × Impact × Discount × ConfidenceWeight
13
13
  //
14
14
  // Industry breach-cost figures used here are sourced from publicly
15
15
  // reported aggregates (Ponemon Cost of a Data Breach Report — IBM/Verizon
@@ -19,6 +19,14 @@
19
19
  //
20
20
  // Disclaimer: this is an order-of-magnitude estimate for prioritization.
21
21
  // It is NOT an actuarial or insurance assessment.
22
+ //
23
+ // FR-803/FR-805 (assurance-hardening PRD): the single `ev` number above is
24
+ // only ever the BASE scenario. Every finding also gets `range` (low/base/
25
+ // high spanning conservative/base/severe), `scenarios` (all three, named),
26
+ // `assumptions` (which table entry and which tier produced each factor),
27
+ // and `modelVersion` — so a reader can trace any dollar figure back to
28
+ // exactly the inputs and methodology version that produced it, and compare
29
+ // scenarios without any of this ever touching `finding.severity`.
22
30
 
23
31
  import * as fs from 'node:fs';
24
32
  import * as path from 'node:path';
@@ -78,36 +86,126 @@ const REACH_DISCOUNT = {
78
86
  'function-reachable-but-not-route':0.4,
79
87
  };
80
88
 
89
+ // Tiny YAML — parses the two flat "section:\n key: number" blocks
90
+ // (impactUSD, familyBaseProb) plus four top-level flat scalar keys (FR-802's
91
+ // organization-specific inputs, added below). Deliberately line-based rather
92
+ // than one multi-line regex: a single regex here
93
+ // (`(?:\s+\w+\s*:\s*\d+\s*\n?)+`) looked correct but had a real, silent bug
94
+ // — `\s*` before the optional trailing `\n?` greedily consumed the NEXT
95
+ // line's leading indentation too, so the repeated group's `+` quantifier
96
+ // only ever matched ONE entry before its required leading `\s+` failed to
97
+ // find any whitespace left to consume. A risk-config.yml with more than one
98
+ // impactUSD entry silently kept only the first — found while adding
99
+ // familyBaseProb support (which inherited the identical bug) and testing
100
+ // it with more than one entry, per this session's standing rule of testing
101
+ // multi-entry cases, not just the single-entry case a hand-rolled parser's
102
+ // own author tends to reach for first.
103
+ //
104
+ // FR-802: `organizationScale`, `industry`, `recordCount`, `controlStrength`
105
+ // are free-form top-level scalars (not sections) — an operator states them
106
+ // directly, e.g. `industry: healthcare`. `recordCount` is parsed as an
107
+ // integer; a non-numeric value is treated as not configured (never silently
108
+ // coerced to NaN, which `!= null` would otherwise count as "present").
109
+ const FLAT_SCALAR_KEYS = ['organizationScale', 'industry', 'recordCount', 'controlStrength'];
110
+
81
111
  function _loadConfig(scanRoot) {
82
112
  const fp = statePath(scanRoot, 'risk-config.yml');
83
113
  if (!fs.existsSync(fp)) return null;
84
114
  try {
85
115
  const body = fs.readFileSync(fp, 'utf8');
86
- // Tiny YAML — look for impactUSD / familyBaseProb overrides
87
116
  const cfg = {};
88
- const impactMatch = body.match(/^impactUSD\s*:\s*\n((?:\s+\w+\s*:\s*\d+\s*\n?)+)/m);
89
- if (impactMatch) {
90
- cfg.impactUSD = {};
91
- for (const m of impactMatch[1].matchAll(/(\w+)\s*:\s*(\d+)/g)) cfg.impactUSD[m[1]] = parseInt(m[2], 10);
117
+ let section = null;
118
+ for (const line of body.split(/\r?\n/)) {
119
+ const sectionMatch = line.match(/^(impactUSD|familyBaseProb)\s*:\s*$/);
120
+ if (sectionMatch) { section = sectionMatch[1]; continue; }
121
+ const flatMatch = !section && line.match(/^([A-Za-z][\w-]*)\s*:\s*(\S.*?)\s*$/);
122
+ if (flatMatch && FLAT_SCALAR_KEYS.includes(flatMatch[1])) {
123
+ if (flatMatch[1] === 'recordCount') {
124
+ const n = parseInt(flatMatch[2], 10);
125
+ if (Number.isFinite(n)) cfg.recordCount = n;
126
+ } else {
127
+ cfg[flatMatch[1]] = flatMatch[2];
128
+ }
129
+ continue;
130
+ }
131
+ if (!section) continue;
132
+ const entryMatch = line.match(/^[ \t]+([\w-]+)\s*:\s*([\d.]+)\s*$/);
133
+ if (entryMatch) {
134
+ if (!cfg[section]) cfg[section] = {};
135
+ cfg[section][entryMatch[1]] = section === 'impactUSD' ? parseInt(entryMatch[2], 10) : parseFloat(entryMatch[2]);
136
+ } else if (line.trim() !== '') {
137
+ section = null; // a non-indented, non-blank line ends the current section
138
+ }
92
139
  }
93
140
  return cfg;
94
141
  } catch { return null; }
95
142
  }
96
143
 
97
- function _baseProb(family) {
144
+ // FR-801/FR-802: every dollar estimate is a SCENARIO by default — generic,
145
+ // industry-wide probability/impact tables, not this organization's actual
146
+ // exposure. `ORG_SPECIFIC_DIMENSIONS` is the full set of tunable knobs this
147
+ // module recognizes (includes `familyBaseProb`, a bonus calibration input
148
+ // that isn't one of FR-802's five named inputs). The stronger claim FR-802
149
+ // gates — "likely organizational loss" — requires ALL FIVE of its literally
150
+ // named inputs (scale, industry, record count, control strength, impact),
151
+ // tracked separately as `REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS` so that
152
+ // configuring `familyBaseProb` alone (or any subset of the five) can never
153
+ // unlock that label — only 'scenario_partially_configured', same as before.
154
+ const ORG_SPECIFIC_DIMENSIONS = ['impactUSD', 'familyBaseProb', 'organizationScale', 'industry', 'recordCount', 'controlStrength'];
155
+ const REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS = ['impactUSD', 'organizationScale', 'industry', 'recordCount', 'controlStrength'];
156
+
157
+ function _scenarioDisclosure(cfg) {
158
+ const configured = ORG_SPECIFIC_DIMENSIONS.filter(d => cfg && cfg[d] != null);
159
+ const unconfigured = ORG_SPECIFIC_DIMENSIONS.filter(d => !configured.includes(d));
160
+ const requiredMissing = REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS.filter(d => !(cfg && cfg[d] != null));
161
+
162
+ let status, message;
163
+ if (requiredMissing.length === 0) {
164
+ status = 'scenario_organization_specific';
165
+ message = `All organization-specific inputs are configured (${REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS.join(', ')}) — this estimate reflects a likely organizational loss for your organization, not a generic industry scenario.`;
166
+ } else if (configured.length > 0) {
167
+ status = 'scenario_partially_configured';
168
+ message = `Uses organization-configured values for: ${configured.join(', ')}. Still missing for a likely-organizational-loss estimate: ${requiredMissing.join(', ')}. This is NOT a likely-organizational-loss estimate.`;
169
+ } else {
170
+ status = 'scenario_default';
171
+ message = 'Uses generic industry-wide scenario defaults. No organization-specific inputs are configured (see .agentic-security/risk-config.yml) — this is NOT a likely-organizational-loss estimate.';
172
+ }
173
+
174
+ return {
175
+ status,
176
+ configuredInputs: configured,
177
+ unconfiguredInputs: unconfigured,
178
+ requiredForOrganizationSpecificLoss: REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS,
179
+ missingRequiredInputs: requiredMissing,
180
+ message,
181
+ };
182
+ }
183
+
184
+ function _baseProb(family, cfg) {
98
185
  if (!family) return 0.05;
186
+ const overrides = cfg && cfg.familyBaseProb;
187
+ if (overrides) {
188
+ const hit = overrides[family] ?? overrides[String(family).toLowerCase()];
189
+ if (typeof hit === 'number') return hit;
190
+ }
99
191
  return FAMILY_BASE_PROB[family] || FAMILY_BASE_PROB[String(family).toLowerCase()] || 0.05;
100
192
  }
101
193
 
194
+ // FR-803: split out from _impactFor so an assumption can name WHICH tier
195
+ // produced the number, not just the number itself.
196
+ function _impactTierOf(finding) {
197
+ const dc = Array.isArray(finding.dataClasses) ? finding.dataClasses : [];
198
+ if (dc.includes('PHI')) return 'PHI';
199
+ if (dc.includes('PCI')) return 'PCI';
200
+ if (dc.includes('PII')) return 'PII';
201
+ if (dc.includes('Confidential')) return 'Confidential';
202
+ if (finding.threatModel?.crownJewel) return 'crown-jewel';
203
+ return 'default';
204
+ }
205
+
102
206
  function _impactFor(finding, cfg) {
103
207
  const table = cfg && cfg.impactUSD ? { ...IMPACT_USD, ...cfg.impactUSD } : IMPACT_USD;
104
- const dc = Array.isArray(finding.dataClasses) ? finding.dataClasses : [];
105
- if (dc.includes('PHI')) return table.PHI;
106
- if (dc.includes('PCI')) return table.PCI;
107
- if (dc.includes('PII')) return table.PII;
108
- if (dc.includes('Confidential')) return table.Confidential;
109
- if (finding.threatModel?.crownJewel) return table['crown-jewel'];
110
- return table.default;
208
+ return table[_impactTierOf(finding)];
111
209
  }
112
210
 
113
211
  // SCA entries carry reachabilityTier/routeReachable (engine.js's SCA
@@ -124,12 +222,16 @@ function _relevanceTierToReachTier(relevanceTier) {
124
222
  }
125
223
  }
126
224
 
127
- function _reachDiscount(finding) {
128
- const tier = finding.reachabilityTier
225
+ // FR-803: split out from _reachDiscount so an assumption can name the tier.
226
+ function _reachTierOf(finding) {
227
+ return finding.reachabilityTier
129
228
  || (finding.routeReachable && 'route-reachable')
130
229
  || _relevanceTierToReachTier(finding.relevanceTier)
131
230
  || 'unknown';
132
- return REACH_DISCOUNT[tier] || 0.3;
231
+ }
232
+
233
+ function _reachDiscount(finding) {
234
+ return REACH_DISCOUNT[_reachTierOf(finding)] || 0.3;
133
235
  }
134
236
 
135
237
  function _epssProb(finding) {
@@ -138,28 +240,111 @@ function _epssProb(finding) {
138
240
  return null;
139
241
  }
140
242
 
243
+ // FR-803: names WHERE the probability number came from, for the assumptions
244
+ // list — not just the value.
245
+ function _probSource(finding, cfg) {
246
+ if (_epssProb(finding) != null) return 'EPSS score (finding-specific)';
247
+ const overrides = cfg && cfg.familyBaseProb;
248
+ if (overrides && (overrides[finding.family] != null || overrides[String(finding.family).toLowerCase()] != null)) {
249
+ return 'operator-configured familyBaseProb override';
250
+ }
251
+ return 'built-in industry base-rate table';
252
+ }
253
+
254
+ // Bump ONLY when the EV formula or a table's underlying MEANING changes
255
+ // (e.g. re-deriving FAMILY_BASE_PROB from a new source) — never for an
256
+ // additive field on the output shape, which stays backward compatible.
257
+ export const RISK_MODEL_VERSION = '1.0.0';
258
+
259
+ // FR-805: scenario multipliers apply only to the two GENERIC assumption
260
+ // inputs — probability-of-exploit and impact-per-incident — that FR-801
261
+ // already discloses as industry-wide defaults. Reachability discount and
262
+ // confidence weight are both measured facts about THIS finding in THIS
263
+ // scan, not assumptions with real-world spread, so they stay fixed across
264
+ // scenarios rather than being perturbed for effect.
265
+ const SCENARIO_MULTIPLIERS = {
266
+ conservative: { prob: 0.5, impact: 0.6 },
267
+ base: { prob: 1.0, impact: 1.0 },
268
+ severe: { prob: 1.75, impact: 1.5 },
269
+ };
270
+
141
271
  /**
142
272
  * Compute EV per finding. Mutates the finding in place: adds
143
- * .riskDollars = { ev, prob, impact, discount }.
273
+ * .riskDollars = { ev, prob, impact, discount, confidenceWeight,
274
+ * scenarioStatus, range: {low, base, high}, scenarios: {conservative,
275
+ * base, severe}, assumptions: [...], modelVersion, confidence }.
144
276
  */
145
277
  export function annotateRiskDollars(scanRoot, findings) {
146
- if (!Array.isArray(findings) || findings.length === 0) return { total: 0, sumEv: 0 };
278
+ const disclosure = _scenarioDisclosure(_loadConfig(scanRoot));
279
+ if (!Array.isArray(findings) || findings.length === 0) return { total: 0, sumEv: 0, scenario: disclosure };
147
280
  const cfg = _loadConfig(scanRoot);
148
281
  let sumEv = 0;
149
282
  let critEv = 0, highEv = 0;
150
283
  for (const f of findings) {
151
284
  const epss = _epssProb(f);
152
- const prob = epss != null ? epss : _baseProb(f.family);
285
+ const prob = epss != null ? epss : _baseProb(f.family, cfg);
286
+ const impactTier = _impactTierOf(f);
153
287
  const impact = _impactFor(f, cfg);
154
- const discount = _reachDiscount(f);
155
- const confidenceFloor = Math.max(0.4, f.confidence || 0.8);
156
- const ev = Math.round(prob * impact * discount * confidenceFloor);
157
- f.riskDollars = { ev, prob: Number(prob.toFixed(3)), impact, discount, confidenceFloor: Number(confidenceFloor.toFixed(2)) };
288
+ const reachTier = _reachTierOf(f);
289
+ const discount = REACH_DISCOUNT[reachTier] || 0.3;
290
+ // FR-804 (assurance-hardening PRD): this used to be
291
+ // `Math.max(0.4, f.confidence || 0.8)` TWO stacked bugs. First, `||`
292
+ // treats a real confidence of 0 (a legitimate, very-low value) as falsy
293
+ // and silently substitutes 0.8, the HIGH default — a genuinely
294
+ // near-zero-confidence finding got the same dollar weight as a
295
+ // near-certain one. Second, the unconditional `Math.max(0.4, ...)`
296
+ // floor inflated every OTHER low-confidence finding (0.05, 0.1, ...) up
297
+ // to 0.4 regardless, which is exactly "an artificially high confidence
298
+ // without an explicit reason" FR-804 names. Fixed: use the finding's
299
+ // real confidence whenever it is genuinely present (including 0), with
300
+ // no floor; 0.8 remains the default ONLY when confidence is absent
301
+ // (not a number at all), which is a "we don't know" default, not an
302
+ // inflation of a known low value.
303
+ const confidenceWeight = typeof f.confidence === 'number' ? f.confidence : 0.8;
304
+
305
+ // FR-805: three named scenarios, sharing the SAME discount/confidence —
306
+ // only the two generic inputs (prob, impact) are perturbed. `ev` (base
307
+ // scenario) is unchanged from before this cycle, since base's
308
+ // multipliers are 1.0/1.0.
309
+ const scenarios = {};
310
+ for (const [name, mult] of Object.entries(SCENARIO_MULTIPLIERS)) {
311
+ const scenarioProb = Math.min(1, prob * mult.prob);
312
+ const scenarioImpact = Math.round(impact * mult.impact);
313
+ scenarios[name] = Math.round(scenarioProb * scenarioImpact * discount * confidenceWeight);
314
+ }
315
+ const ev = scenarios.base;
316
+
317
+ // FR-803: every dollar value traces to its own assumptions and a model
318
+ // version — not just a bare number.
319
+ const assumptions = [
320
+ `probability of exploit: ${Number(prob.toFixed(3))} (source: ${_probSource(f, cfg)})`,
321
+ `impact estimate: ${fmtUsd(impact)} (tier: ${impactTier}, ${cfg?.impactUSD?.[impactTier] != null ? 'operator-configured' : 'built-in default'})`,
322
+ `reachability discount: ${discount} (tier: ${reachTier})`,
323
+ `confidence weight: ${Number(confidenceWeight.toFixed(2))} (${typeof f.confidence === 'number' ? 'measured from this scan' : 'unknown — defaulted'})`,
324
+ ];
325
+ // FR-803: a distinct confidence indicator on the estimate itself — not
326
+ // the per-finding confidenceWeight multiplier, but a coarse tier
327
+ // reflecting how much of this number is organization-specific vs.
328
+ // generic-default, folded with the finding's own detection confidence.
329
+ const confidence = disclosure.status === 'scenario_organization_specific' && confidenceWeight >= 0.7
330
+ ? 'high'
331
+ : (disclosure.status === 'scenario_default' || confidenceWeight < 0.4 ? 'low' : 'medium');
332
+
333
+ f.riskDollars = {
334
+ ev, prob: Number(prob.toFixed(3)), impact, discount,
335
+ confidenceWeight: Number(confidenceWeight.toFixed(2)),
336
+ scenarioStatus: disclosure.status,
337
+ range: { low: scenarios.conservative, base: scenarios.base, high: scenarios.severe },
338
+ scenarios,
339
+ assumptions,
340
+ modelVersion: RISK_MODEL_VERSION,
341
+ confidence,
342
+ };
158
343
  sumEv += ev;
159
344
  if (f.severity === 'critical') critEv += ev;
160
345
  else if (f.severity === 'high') highEv += ev;
161
346
  }
162
- return { total: findings.length, sumEv, critEv, highEv };
347
+ return { total: findings.length, sumEv, critEv, highEv, scenario: disclosure };
163
348
  }
164
349
 
165
350
  /**
@@ -172,4 +357,9 @@ export function fmtUsd(n) {
172
357
  return `$${n}`;
173
358
  }
174
359
 
175
- export const _internals = { FAMILY_BASE_PROB, IMPACT_USD, REACH_DISCOUNT, _baseProb, _impactFor, _reachDiscount };
360
+ export const _internals = {
361
+ FAMILY_BASE_PROB, IMPACT_USD, REACH_DISCOUNT, ORG_SPECIFIC_DIMENSIONS,
362
+ REQUIRED_FOR_ORGANIZATION_SPECIFIC_LOSS, SCENARIO_MULTIPLIERS,
363
+ _baseProb, _impactFor, _impactTierOf, _reachDiscount, _reachTierOf,
364
+ _probSource, _loadConfig, _scenarioDisclosure,
365
+ };
@@ -24,7 +24,8 @@ import * as fs from 'node:fs';
24
24
  import { statePath, stateWritesEnabled } from './state-dir.js';
25
25
  import * as path from 'node:path';
26
26
  import * as crypto from 'node:crypto';
27
- import { execSync } from 'node:child_process';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
28
29
 
29
30
  const HISTORY_DIR = 'sbom-history';
30
31
 
@@ -32,9 +33,21 @@ function _historyDir(scanRoot) {
32
33
  return statePath(scanRoot, HISTORY_DIR);
33
34
  }
34
35
 
36
+ // `scanRoot` is the scanned project's repository, not this project's own
37
+ // trusted checkout — hardened per FR-PROV-024 / the second Finding
38
+ // Provenance PRD audit sweep (found missing here by a follow-up review that
39
+ // grepped for `child_process` usage beyond just `execFileSync('git'` call
40
+ // sites). `rev-parse HEAD` was VERIFIED not to itself trigger
41
+ // `core.fsmonitor`/a hook (it touches neither the working tree nor the
42
+ // index), so this is not a second live RCE — but leaving
43
+ // `GIT_CONFIG_NOSYSTEM`/`GIT_TERMINAL_PROMPT`/`core.hooksPath` unset here
44
+ // still violated this module's own "every git call routes through
45
+ // hardenGitArgs/hardenGitEnv" contract, and `execSync` (a shell string) is
46
+ // gratuitous risk this call never needed — `rev-parse HEAD` has no
47
+ // caller-controlled input to interpolate at all.
35
48
  function _gitHead(scanRoot) {
36
49
  try {
37
- return execSync('git rev-parse HEAD', { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
50
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), { cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv() }).trim();
38
51
  } catch { return null; }
39
52
  }
40
53