@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,154 @@
1
+ // Code-derived privacy data inventory + flow graph (assurance-hardening
2
+ // PRD FR-406).
3
+ //
4
+ // Turns the raw signals privacy-taint.js already computes (piiFields,
5
+ // findings, policyExemptions — the latter two now carrying sourceName/
6
+ // sourceLine, added for this requirement) into per-source-flow inventory
7
+ // records, each carrying the PRD's named fields: data class, source,
8
+ // transformations, storage, sink/recipient, evidence locations.
9
+ //
10
+ // "transformations" is honestly disclosed as the literal sentinel
11
+ // NOT_MODELED for every record — this engine has no sanitizer/
12
+ // transformation tracking for privacy flows specifically; that is FR-403's
13
+ // deferred scope (confirmed large this session, needing "an actual
14
+ // dataflow-aware rewrite"). Fabricating a transformation chain this engine
15
+ // cannot see would be exactly the vacuous-satisfaction failure mode
16
+ // FR-405/FR-407 both guard against for their own fields — this module
17
+ // applies the identical discipline here rather than inventing partial
18
+ // credit.
19
+ //
20
+ // STORAGE_SINK_KINDS names the subset of privacy-taint.js's sink
21
+ // categories that represent data AT REST (file writes, object storage) as
22
+ // opposed to data IN TRANSIT (log, response, outbound HTTP, third-party
23
+ // SDK, email) — this is what separates the PRD's "storage" field from its
24
+ // "sink/recipient" field. It is necessarily incomplete: this engine has no
25
+ // sink entries for a database write, a message queue, or most managed
26
+ // storage services, so an empty storage array does NOT mean "no storage
27
+ // exists" — the artifact says so explicitly rather than implying a
28
+ // negative that was never checked.
29
+
30
+ export const NOT_MODELED = 'not_modeled';
31
+ export const STORAGE_SINK_KINDS = new Set(['fileWrite', 's3Upload']);
32
+
33
+ function _evidenceLocations(record) {
34
+ const locs = [];
35
+ if (record.source) locs.push({ file: record.source.file, line: record.source.line, role: 'source' });
36
+ for (const s of record.storage) locs.push({ file: s.file, line: s.line, role: 'storage' });
37
+ for (const r of record.sinkRecipient) locs.push({ file: r.file, line: r.line, role: 'recipient' });
38
+ return locs;
39
+ }
40
+
41
+ /**
42
+ * Build the data inventory: one record per (data class, source variable)
43
+ * pair actually observed reaching at least one sink, prohibited or
44
+ * policy-permitted. A source with no observed sink at all (declared but
45
+ * never flowed anywhere the annotator's sink catalog recognizes) is NOT
46
+ * included — this is an inventory of REAL, observed flows, not a listing
47
+ * of every regulated-looking field name.
48
+ */
49
+ export function buildDataInventory(piiFields, findings, policyExemptions) {
50
+ // Key: `${file}::${sourceName}::${classes.join(',')}` — the same source
51
+ // variable can carry more than one class (e.g. name+PII, ssn+PII), and a
52
+ // record must not merge two variables that happen to share a name across
53
+ // different files.
54
+ const byKey = new Map();
55
+
56
+ const declByFileName = new Map(); // `${file}::${name}` -> piiField (for declaredType / line fallback)
57
+ for (const f of piiFields || []) {
58
+ declByFileName.set(`${f.file}::${f.name}`, f);
59
+ }
60
+
61
+ function recordFor(file, name, classes, sourceLine) {
62
+ const key = `${file}::${name}::${classes.join(',')}`;
63
+ let rec = byKey.get(key);
64
+ if (!rec) {
65
+ const decl = declByFileName.get(`${file}::${name}`);
66
+ rec = {
67
+ dataClass: classes,
68
+ source: { file, name, line: sourceLine ?? decl?.line ?? null, declaredType: decl?.declaredType || null },
69
+ transformations: NOT_MODELED,
70
+ storage: [],
71
+ sinkRecipient: [],
72
+ };
73
+ byKey.set(key, rec);
74
+ }
75
+ return rec;
76
+ }
77
+
78
+ for (const f of findings || []) {
79
+ if (f.family !== 'pii-exposure' || !f.sourceName) continue;
80
+ const rec = recordFor(f.file, f.sourceName, f.piiClass || [], f.sourceLine);
81
+ const entry = { sinkKind: f.sinkKind, file: f.file, line: f.line, status: 'prohibited', severity: f.severity };
82
+ if (STORAGE_SINK_KINDS.has(f.sinkKind)) rec.storage.push(entry);
83
+ else rec.sinkRecipient.push(entry);
84
+ }
85
+ for (const e of policyExemptions || []) {
86
+ if (!e.name) continue;
87
+ const rec = recordFor(e.file, e.name, e.classes || [], e.sourceLine);
88
+ const reasons = (e.rules || []).map(r => r.reason).filter(Boolean);
89
+ const entry = { sinkKind: e.sinkKind, file: e.file, line: e.line, status: 'policy_permitted', reason: reasons[0] || null };
90
+ if (STORAGE_SINK_KINDS.has(e.sinkKind)) rec.storage.push(entry);
91
+ else rec.sinkRecipient.push(entry);
92
+ }
93
+
94
+ const records = [...byKey.values()];
95
+ for (const rec of records) rec.evidenceLocations = _evidenceLocations(rec);
96
+ records.sort((a, b) => (a.source.file + a.source.name).localeCompare(b.source.file + b.source.name));
97
+ return records;
98
+ }
99
+
100
+ /**
101
+ * Emit the machine-readable inventory artifact.
102
+ */
103
+ export function emitDataInventoryArtifact(records) {
104
+ return JSON.stringify({
105
+ schemaNote: 'Code-derived privacy data inventory (assurance-hardening PRD FR-406). "transformations" is always "not_modeled" — this engine does not track sanitization/masking/encoding for privacy flows (see FR-403). "storage" is best-effort — only file-write and object-storage sinks are recognized; a database, queue, or managed storage service reached through an unrecognized API will not appear here.',
106
+ generatedAt: new Date().toISOString(),
107
+ records,
108
+ }, null, 2);
109
+ }
110
+
111
+ /**
112
+ * Emit a Mermaid flow-graph: one edge per source -> sink/storage
113
+ * relationship, decorated with the data class and whether the flow is
114
+ * prohibited (still an open finding) or policy-permitted.
115
+ */
116
+ export function emitDataFlowGraph(records) {
117
+ const lines = [];
118
+ lines.push('# Privacy data flow graph (assurance-hardening PRD FR-406)');
119
+ lines.push('');
120
+ lines.push('Code-derived — one edge per OBSERVED source -> sink/storage flow.');
121
+ lines.push('A red edge is an open (prohibited) finding; a green edge is policy-permitted.');
122
+ lines.push('');
123
+ lines.push('```mermaid');
124
+ lines.push('graph LR');
125
+ const nodeIds = new Map();
126
+ let n = 0;
127
+ function idFor(label) {
128
+ if (!nodeIds.has(label)) nodeIds.set(label, `n${n++}`);
129
+ return nodeIds.get(label);
130
+ }
131
+ if (!records.length) {
132
+ lines.push(' EMPTY["No regulated-data flows observed"]');
133
+ }
134
+ const edgeLines = [];
135
+ const styleLines = [];
136
+ let edgeIndex = 0;
137
+ for (const rec of records) {
138
+ const srcLabel = `${rec.source.name}\\n(${rec.dataClass.join('+')})\\n${rec.source.file}:${rec.source.line ?? '?'}`;
139
+ const srcId = idFor(srcLabel);
140
+ lines.push(` ${srcId}["${srcLabel}"]`);
141
+ for (const entry of [...rec.storage, ...rec.sinkRecipient]) {
142
+ const sinkLabel = `${entry.sinkKind}\\n${entry.file}:${entry.line}`;
143
+ const sinkId = idFor(sinkLabel);
144
+ lines.push(` ${sinkId}[["${sinkLabel}"]]`);
145
+ edgeLines.push(` ${srcId} --> ${sinkId}`);
146
+ const color = entry.status === 'prohibited' ? '#c0392b' : '#27ae60';
147
+ styleLines.push(` linkStyle ${edgeIndex} stroke:${color}`);
148
+ edgeIndex++;
149
+ }
150
+ }
151
+ lines.push(...edgeLines, ...styleLines);
152
+ lines.push('```');
153
+ return lines.join('\n');
154
+ }
@@ -0,0 +1,125 @@
1
+ // Privacy sink policy (assurance-hardening PRD FR-404).
2
+ //
3
+ // `annotatePrivacyTaint` treats every SINK_PATTERNS match as unconditionally
4
+ // prohibited — any regulated-data class reaching log/response/outboundHttp/
5
+ // etc. always produces a finding, with no way to say "PII flowing to
6
+ // emailSend is expected here (password-reset emails), don't flag it." FR-404
7
+ // asks specifically for a POLICY-gated version of that emission: a finding
8
+ // still fires by default (nothing changes for a repo with no policy file —
9
+ // this is what keeps the PRD's own worked example, "email from request
10
+ // input to logging produces a mapped privacy finding", true unmodified), but
11
+ // an operator can mark a specific (data class, sink) pair — or a sink
12
+ // entirely, for every class — as policy-permitted, which suppresses the
13
+ // finding.
14
+ //
15
+ // Extended for FR-408 ("repository privacy policy defining prohibited and
16
+ // conditionally permitted flows... environment- and destination-specific
17
+ // decisions") with two additional, OPTIONAL rule axes: `environment` (this
18
+ // scan's deployment environment — resolved from AGENTIC_SECURITY_ENVIRONMENT,
19
+ // never from NODE_ENV, which this codebase's own env-hygiene detector
20
+ // already flags as "unreliable as a security boundary" for application
21
+ // code, and the same reasoning applies to a scanner's own policy decisions)
22
+ // and `destination` (a regex matched against the actual sink expression
23
+ // text — "stripe.track", not just the broader "thirdPartySdk" category).
24
+ // Both axes are ADDITIVE constraints and FAIL CLOSED: a rule naming an
25
+ // environment or destination the caller did not supply context for does
26
+ // NOT match — an unknown environment must never silently satisfy an
27
+ // environment-scoped permission, which is the entire point of adding the
28
+ // axis. A rule with neither field set behaves exactly as it did under
29
+ // FR-404 (unconstrained by environment/destination), so every FR-404 rule
30
+ // written before this extension keeps working unmodified.
31
+ //
32
+ // Suppression must be VISIBLE, not silent — same principle the root
33
+ // CLAUDE.md states for ignore-pragma suppressions ("a suppression nobody can
34
+ // see is indistinguishable from a finding that never fired"). A permitted
35
+ // flow is recorded on the annotator's `policyExemptions` array (see
36
+ // privacy-taint.js), never just dropped.
37
+
38
+ import * as fs from 'node:fs';
39
+ import { statePath } from '../posture/state-dir.js';
40
+
41
+ function _policyStatePath(scanRoot) {
42
+ return statePath(scanRoot, 'privacy-policy.json');
43
+ }
44
+
45
+ /**
46
+ * Load the operator's privacy sink policy. Never throws — a missing file
47
+ * (ENOENT) is the common "no policy configured" case; a malformed one logs
48
+ * a warning and degrades to the empty policy (everything prohibited, the
49
+ * pre-FR-404 default). Returns { allow: [{sink, class?, reason?}] }.
50
+ */
51
+ export function loadPrivacySinkPolicy(scanRoot) {
52
+ const EMPTY = { allow: [] };
53
+ if (!scanRoot) return EMPTY;
54
+ const fp = _policyStatePath(scanRoot);
55
+ let raw = null;
56
+ try {
57
+ raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
58
+ } catch (e) {
59
+ if (e.code !== 'ENOENT') {
60
+ console.error(`agentic-security: bad JSON in privacy-policy.json — falling back to no policy (${e.message})`);
61
+ }
62
+ return EMPTY;
63
+ }
64
+ const allow = Array.isArray(raw?.allow)
65
+ ? raw.allow.filter(r => r && typeof r.sink === 'string' && r.sink)
66
+ : [];
67
+ return { allow };
68
+ }
69
+
70
+ // FR-408: does `ruleEnv` (unset | string | string[]) cover `currentEnv`?
71
+ // Unset is unconstrained (matches anything, including an unknown
72
+ // environment — preserves FR-404 rules verbatim). Set-but-unknown-current
73
+ // fails closed: a rule scoped to an environment the caller never told us
74
+ // about must not match by accident.
75
+ function _matchesEnvironment(ruleEnv, currentEnv) {
76
+ if (ruleEnv == null) return true;
77
+ if (!currentEnv) return false;
78
+ const allowed = Array.isArray(ruleEnv) ? ruleEnv : [ruleEnv];
79
+ return allowed.some(e => typeof e === 'string' && e.toLowerCase() === String(currentEnv).toLowerCase());
80
+ }
81
+
82
+ // FR-408: does `rulePattern` (unset | regex source string) match `destText`
83
+ // (the raw sink expression, e.g. "stripe.track")? Same fail-closed shape as
84
+ // environment — an invalid operator-supplied regex never silently matches
85
+ // everything, and an unset destText for a destination-scoped rule doesn't
86
+ // match either.
87
+ function _matchesDestination(rulePattern, destText) {
88
+ if (rulePattern == null) return true;
89
+ if (!destText) return false;
90
+ try { return new RegExp(rulePattern, 'i').test(destText); } catch { return false; }
91
+ }
92
+
93
+ function _rulesForSink(policy, sinkKind, ctx) {
94
+ const allow = Array.isArray(policy?.allow) ? policy.allow : [];
95
+ return allow.filter(r => r.sink === sinkKind
96
+ && _matchesEnvironment(r.environment, ctx?.environment)
97
+ && _matchesDestination(r.destination, ctx?.destination));
98
+ }
99
+
100
+ /**
101
+ * Is `sinkKind` policy-permitted for every class in `classes`, in the
102
+ * given context (`{environment?, destination?}`, both optional)? A rule
103
+ * matches a class either explicitly (`rule.class === cls`) or by covering
104
+ * the sink for ANY class (`rule.class` unset), AND must satisfy any
105
+ * environment/destination constraint the rule itself declares (FR-408).
106
+ * Returns true only when every matched class has a covering rule — a
107
+ * finding combining a permitted class with an unpermitted one (e.g.
108
+ * "PII+CREDENTIALS both flow to log, but only PII→log was allowed") must
109
+ * still fire, not be silently swept away by a partial allow rule.
110
+ */
111
+ export function isSinkPermitted(classes, sinkKind, policy, ctx = {}) {
112
+ if (!Array.isArray(classes) || !classes.length) return false;
113
+ const rulesForSink = _rulesForSink(policy, sinkKind, ctx);
114
+ if (!rulesForSink.length) return false;
115
+ return classes.every(cls => rulesForSink.some(r => !r.class || r.class === cls));
116
+ }
117
+
118
+ /**
119
+ * The specific rule(s) that permitted a class for a sink in the given
120
+ * context, for exemption disclosure (reason strings, when the operator
121
+ * supplied one).
122
+ */
123
+ export function permittingRules(classes, sinkKind, policy, ctx = {}) {
124
+ return _rulesForSink(policy, sinkKind, ctx).filter(r => !r.class || classes.includes(r.class));
125
+ }
@@ -20,45 +20,26 @@
20
20
  // attempt content classification (Luhn-checking actual values would
21
21
  // only catch leaks that have already happened); we classify by NAME
22
22
  // + TYPE in declarations.
23
+ //
24
+ // The data-class taxonomy itself (PII/PHI/PCI/FIN/CREDENTIALS/GEOLOCATION/
25
+ // DEVICE_ID, plus any organization-defined classes) is versioned and
26
+ // customizable without editing this file — see ./privacy-taxonomy.js
27
+ // (assurance-hardening PRD FR-402). This module keeps only the sink
28
+ // taxonomy (where regulated data exits), which is not in FR-402's scope.
29
+ //
30
+ // Whether a class-reaches-sink flow actually PRODUCES a finding is
31
+ // policy-gated (assurance-hardening PRD FR-404) — see
32
+ // ./privacy-sink-policy.js. With no policy configured every match is
33
+ // prohibited, unchanged from before FR-404.
23
34
 
24
- // PII / PHI / PCI / FIN classifiers — each is a regex against
25
- // field/variable/column names. Same idea as the existing classifyField
26
- // helpers in engine.js but enumerated for compliance reporting.
35
+ import {
36
+ BUILTIN_TAXONOMY_VERSION, DEFAULT_TAXONOMY, compileTaxonomy,
37
+ loadPrivacyTaxonomy, classifyFieldAgainst, severityForClasses,
38
+ } from './privacy-taxonomy.js';
39
+ import { loadPrivacySinkPolicy, isSinkPermitted, permittingRules } from './privacy-sink-policy.js';
40
+ import { GOVERNANCE_FIELDS, governanceRecordFor } from './privacy-governance.js';
27
41
 
28
- const PII_PATTERNS = {
29
- PII: [
30
- /\bfirst[_-]?name\b/i, /\blast[_-]?name\b/i, /\bfull[_-]?name\b/i,
31
- /\bemail([_-]?address)?\b/i, /\bphone([_-]?number)?\b/i, /\bmobile\b/i,
32
- /\baddress(?:_?(?:line|street|city|zip|postal))?\b/i,
33
- /\bdob\b/i, /\bdate[_-]?of[_-]?birth\b/i, /\bbirthday\b/i, /\bbirthdate\b/i,
34
- /\bage\b/i, /\bgender\b/i, /\bethnicity\b/i, /\brace\b/i, /\bnationality\b/i,
35
- /\bssn\b/i, /\bsocial[_-]?security/i, /\bnational[_-]?id/i, /\bpassport\b/i,
36
- /\bdriver[_-]?license\b/i, /\btax[_-]?id\b/i, /\bgovernment[_-]?id\b/i,
37
- /\bip[_-]?address\b/i, /\bgeo[_-]?location\b/i, /\blatitude\b/i, /\blongitude\b/i,
38
- ],
39
- PHI: [
40
- /\b(?:medical|patient|health)[_-]?record\b/i,
41
- /\bdiagnosis\b/i, /\bcondition\b/i, /\bsymptom\b/i, /\btreatment\b/i,
42
- /\bmedication\b/i, /\bprescription\b/i, /\bdosage\b/i,
43
- /\bicd[_-]?(?:9|10|11)\b/i, /\bcpt[_-]?code\b/i, /\bmrn\b/i,
44
- /\bmedical[_-]?record[_-]?number\b/i, /\bdoctor[_-]?name\b/i,
45
- /\bphysician\b/i, /\binsurance[_-]?id\b/i, /\bhealth[_-]?plan\b/i,
46
- ],
47
- PCI: [
48
- /\bcredit[_-]?card[_-]?(?:number|num|no)?\b/i,
49
- /\bcard[_-]?(?:number|num|no)\b/i,
50
- /\b(?:cvc|cvv)2?\b/i, /\bcvc[_-]?code\b/i,
51
- /\bexp(?:iry|iration)?(?:_?date)?\b/i,
52
- /\bcardholder[_-]?name\b/i, /\bpan\b/i,
53
- /\biban\b/i, /\brouting[_-]?number\b/i,
54
- /\baccount[_-]?number\b/i,
55
- ],
56
- FIN: [
57
- /\bsalary\b/i, /\bincome\b/i, /\bbalance\b/i, /\btransaction[_-]?amount\b/i,
58
- /\bbank[_-]?account\b/i,
59
- /\bcredit[_-]?score\b/i, /\bnet[_-]?worth\b/i,
60
- ],
61
- };
42
+ const _BUILTIN_COMPILED = compileTaxonomy(DEFAULT_TAXONOMY);
62
43
 
63
44
  const SINK_PATTERNS = {
64
45
  log: /\b(?:log|logger|console|System\.out|System\.err|stdout|stderr|fmt\.Print|print)\b/i,
@@ -71,18 +52,15 @@ const SINK_PATTERNS = {
71
52
  };
72
53
 
73
54
  /**
74
- * Classify a field/variable name into PII / PHI / PCI / FIN buckets.
55
+ * Classify a field/variable name into the built-in taxonomy's buckets
56
+ * (PII / PHI / PCI / FIN / CREDENTIALS / GEOLOCATION / DEVICE_ID).
75
57
  * Returns an array of bucket labels (possibly empty, possibly multiple).
58
+ * `compiled` is an optional pre-compiled taxonomy (see
59
+ * privacy-taxonomy.js's compileTaxonomy / loadPrivacyTaxonomy) — omit it
60
+ * to classify against the built-in defaults only.
76
61
  */
77
- export function classifyField(name) {
78
- if (!name) return [];
79
- const out = [];
80
- for (const [bucket, patterns] of Object.entries(PII_PATTERNS)) {
81
- for (const p of patterns) {
82
- if (p.test(name)) { out.push(bucket); break; }
83
- }
84
- }
85
- return out;
62
+ export function classifyField(name, compiled) {
63
+ return classifyFieldAgainst(name, compiled || _BUILTIN_COMPILED);
86
64
  }
87
65
 
88
66
  /**
@@ -101,19 +79,51 @@ export function classifySink(expr) {
101
79
  * a privacy-leak finding when a regulated class reaches a non-secure
102
80
  * sink (log, response, outbound HTTP, etc.).
103
81
  */
104
- export function annotatePrivacyTaint(perFileIR) {
105
- if (!perFileIR) return { findings: [], piiFields: [] };
82
+ export function annotatePrivacyTaint(perFileIR, opts = {}) {
83
+ // FR-402 (assurance-hardening PRD): the taxonomy used for classification
84
+ // is resolved once per call, not hardcoded — `opts.compiled` lets a
85
+ // caller (tests, or a future dry-run) hand in an already-compiled
86
+ // taxonomy directly; `opts.scanRoot` loads and compiles the effective
87
+ // (built-in + operator-config) taxonomy from disk. Neither is required:
88
+ // omitting both classifies against the built-in defaults, unchanged from
89
+ // before FR-402.
90
+ const { version, compiled } = opts.compiled
91
+ ? { version: opts.taxonomyVersion || BUILTIN_TAXONOMY_VERSION, compiled: opts.compiled }
92
+ : (opts.scanRoot ? loadPrivacyTaxonomy(opts.scanRoot) : { version: BUILTIN_TAXONOMY_VERSION, compiled: _BUILTIN_COMPILED });
93
+ // FR-404 (assurance-hardening PRD): whether a class-reaches-sink flow is
94
+ // PROHIBITED is policy-gated the same way — `opts.sinkPolicy` for a
95
+ // caller that already has one, `opts.scanRoot` to load
96
+ // .agentic-security/privacy-policy.json, or the empty policy (everything
97
+ // prohibited, the pre-FR-404 default) when neither is given.
98
+ const sinkPolicy = opts.sinkPolicy || (opts.scanRoot ? loadPrivacySinkPolicy(opts.scanRoot) : { allow: [] });
99
+ // FR-408: the current deployment environment, ONLY from an explicit
100
+ // caller-supplied opts.environment or AGENTIC_SECURITY_ENVIRONMENT — never
101
+ // NODE_ENV (see privacy-sink-policy.js's header for why). Unset means an
102
+ // environment-scoped allow rule never matches (fail closed), same as
103
+ // before this option existed.
104
+ const policyCtx = { environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null };
105
+ if (!perFileIR) return { findings: [], piiFields: [], taxonomyVersion: version, policyExemptions: [] };
106
106
  const findings = [];
107
107
  const piiFields = [];
108
+ // Suppression must be visible, not silent — same principle as the
109
+ // ignore-pragma suppression ledger (root CLAUDE.md): a policy-permitted
110
+ // flow is recorded here, never just dropped.
111
+ const policyExemptions = [];
108
112
  for (const [filePath, ir] of (perFileIR instanceof Map ? perFileIR : Object.entries(perFileIR))) {
109
113
  if (!ir || !ir._content) continue;
110
114
  const lines = ir._content.split('\n');
111
115
  // Step 1: collect PII-classified decls.
112
116
  const taintedVars = new Map(); // name → array of bucket labels
117
+ // FR-406: declaration line per source variable, so a finding/exemption
118
+ // emitted below can link back to where the regulated data ENTERED
119
+ // (evidence locations must cover both ends of a flow, not just the
120
+ // sink) without re-deriving it from piiFields by proximity guessing.
121
+ const declLineByName = new Map();
113
122
  for (const d of ir.decls || []) {
114
- const classes = classifyField(d.name);
123
+ const classes = classifyFieldAgainst(d.name, compiled);
115
124
  if (classes.length) {
116
125
  taintedVars.set(d.name, classes);
126
+ declLineByName.set(d.name, d.line);
117
127
  piiFields.push({ file: filePath, line: d.line, name: d.name, classes, declaredType: d.type || null });
118
128
  }
119
129
  }
@@ -121,26 +131,45 @@ export function annotatePrivacyTaint(perFileIR) {
121
131
  // reaching a sink.
122
132
  for (const call of ir.calls || []) {
123
133
  const argText = (call.args || []).map(a => a.text || '').join(',');
124
- const sinkLabel = classifySink(call.fullPath || call.callee || '');
134
+ // FR-408: the raw sink expression text, e.g. "stripe.track" — the
135
+ // destination-matching axis needs the actual call identity, not just
136
+ // its broader sink CATEGORY (sinkLabel), which "stripe.track" and
137
+ // "axios.post" would otherwise share.
138
+ const destText = call.fullPath || call.callee || '';
139
+ const sinkLabel = classifySink(destText);
125
140
  if (!sinkLabel) continue;
141
+ const ctx = { ...policyCtx, destination: destText };
126
142
  for (const [name, classes] of taintedVars) {
127
143
  if (!new RegExp(`\\b${name.replace(/[.+^${}()|\\]/g, '\\$&')}\\b`).test(argText)) continue;
144
+ const sourceLine = declLineByName.get(name) ?? null;
145
+ if (isSinkPermitted(classes, sinkLabel, sinkPolicy, ctx)) {
146
+ policyExemptions.push({
147
+ file: filePath, line: call.line, name, classes, sinkKind: sinkLabel,
148
+ sourceLine,
149
+ rules: permittingRules(classes, sinkLabel, sinkPolicy, ctx).map(r => ({ sink: r.sink, class: r.class || null, reason: r.reason || null, environment: r.environment || null, destination: r.destination || null })),
150
+ });
151
+ continue;
152
+ }
128
153
  findings.push({
129
154
  family: 'pii-exposure',
130
155
  subfamily: classes.join('+'),
131
156
  file: filePath, line: call.line,
132
- severity: classes.includes('PCI') || classes.includes('PHI') ? 'high' : 'medium',
157
+ severity: severityForClasses(classes, compiled),
133
158
  cwe: 'CWE-359', // Exposure of Private Personal Information
134
159
  vuln: `Privacy — ${classes.join('+')} data flows to ${sinkLabel} sink`,
135
160
  snippet: (lines[call.line - 1] || '').trim().slice(0, 200),
136
161
  remediation: `${classes.join(' + ')} data must not flow to ${sinkLabel} unencrypted. Mask, redact, or hash the value before logging / responding / sending to third parties.`,
137
162
  piiClass: classes,
138
163
  sinkKind: sinkLabel,
164
+ // FR-406: evidence linkage back to the source declaration, not
165
+ // just the sink call site.
166
+ sourceName: name,
167
+ sourceLine,
139
168
  });
140
169
  }
141
170
  }
142
171
  }
143
- return { findings, piiFields };
172
+ return { findings, piiFields, taxonomyVersion: version, policyExemptions };
144
173
  }
145
174
 
146
175
  /**
@@ -167,6 +196,12 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
167
196
  lines.push('');
168
197
  lines.push(`## Data classes identified`);
169
198
  lines.push('');
199
+ // FR-407: governance fields (purpose, lawful basis, subject, retention,
200
+ // residency, recipient, transfer, minimization, consent, access,
201
+ // deletion) — none inferable from code, so every one is either
202
+ // operator-supplied (opts.governanceConfig) or explicitly manual_required,
203
+ // never blank and never guessed.
204
+ const governanceConfig = opts.governanceConfig || null;
170
205
  for (const [cls, fields] of grouped) {
171
206
  lines.push(`### ${cls} (${fields.length} fields)`);
172
207
  lines.push('');
@@ -175,6 +210,14 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
175
210
  }
176
211
  if (fields.length > 20) lines.push(`- … and ${fields.length - 20} more`);
177
212
  lines.push('');
213
+ lines.push(`**Governance fields for ${cls}** (see the RoPA artifact for the full register):`);
214
+ lines.push('');
215
+ const record = governanceRecordFor(cls, governanceConfig);
216
+ for (const field of GOVERNANCE_FIELDS) {
217
+ const r = record[field];
218
+ lines.push(`- ${field}: \`${r.value}\`${r.source === 'operator_provided' ? ' (operator-provided)' : ''}`);
219
+ }
220
+ lines.push('');
178
221
  }
179
222
  lines.push(`## Privacy-related findings`);
180
223
  lines.push('');
@@ -185,6 +228,24 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
185
228
  }
186
229
  if (findings.length > 50) lines.push(`| … | … | … | … and ${findings.length - 50} more |`);
187
230
  lines.push('');
231
+ // FR-404: a policy-permitted flow must stay visible here, not vanish —
232
+ // same principle as the ignore-pragma suppression ledger (root
233
+ // CLAUDE.md). Only rendered when the policy actually exempted something.
234
+ const exemptions = Array.isArray(opts.policyExemptions) ? opts.policyExemptions : [];
235
+ if (exemptions.length) {
236
+ lines.push(`## Policy-permitted flows (not flagged above)`);
237
+ lines.push('');
238
+ lines.push(`These flows matched a regulated-data class reaching a sink, but were permitted by .agentic-security/privacy-policy.json and are excluded from the findings table above.`);
239
+ lines.push('');
240
+ lines.push(`| File:Line | Class → Sink | Reason |`);
241
+ lines.push(`|---|---|---|`);
242
+ for (const e of exemptions.slice(0, 50)) {
243
+ const reason = (e.rules || []).map(r => r.reason).filter(Boolean).join('; ') || '(no reason given)';
244
+ lines.push(`| ${e.file}:${e.line} | ${(e.classes || []).join('+')} → ${e.sinkKind} | ${reason} |`);
245
+ }
246
+ if (exemptions.length > 50) lines.push(`| … | … | … and ${exemptions.length - 50} more |`);
247
+ lines.push('');
248
+ }
188
249
  lines.push(`## Regulatory framework mapping`);
189
250
  lines.push('');
190
251
  lines.push(`- **GDPR Art. 35** — DPIA required when processing is likely to result in high risk to data subjects.`);
@@ -202,4 +263,4 @@ export function emitDpiaArtifact(piiFields, findings, opts = {}) {
202
263
  return lines.join('\n');
203
264
  }
204
265
 
205
- export const _internals = { PII_PATTERNS, SINK_PATTERNS };
266
+ export const _internals = { DEFAULT_TAXONOMY, SINK_PATTERNS };