@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,427 @@
1
+ //
2
+ // sink-registry.js — Data Flow Explorer, Sub-project D, increment D3.
3
+ //
4
+ // Reclassifies TWO separate catalogs into DataFlowGraph v1's
5
+ // `SINK_CATEGORIES` / `coverageStatus` vocabulary (`schema.js`):
6
+ // - scanner/src/dataflow/catalog.js's `kind: 'sink'` entries (194 as of
7
+ // this writing — re-derive via `CATALOG.filter(e => e.kind ===
8
+ // 'sink').length` rather than trusting this comment; the
9
+ // dataflow/CLAUDE.md count has already drifted twice), keyed on
10
+ // `vuln.cwe` — reclassifySink(entry).
11
+ // - scanner/src/dataflow/privacy-catalog.js's PRIVACY_SINK_CATALOG (18
12
+ // entries), keyed on `category` — reclassifyPrivacySink(entry).
13
+ //
14
+ // TWO separate functions, deliberately, not a unified dispatcher: the two
15
+ // catalogs key on different fields (DESIGN_REGISTRIES.md §2.1), and
16
+ // privacy-catalog.js is deliberately NOT merged into CATALOG (merging it
17
+ // would make every already-active general source spuriously trigger
18
+ // privacy-leak findings — see that file's own header). Reclassifying the two
19
+ // through separate functions respects that boundary; a unified dispatcher
20
+ // would have to re-merge them internally to work at all.
21
+ //
22
+ // This module is the MECHANICAL implementation of a binding, independently
23
+ // reviewed design — it is not a fresh design decision (except where noted
24
+ // below for FR-203, which the design specifies field-by-field but not as
25
+ // runnable code). Every mapping row traces to `DESIGN_REGISTRIES.md`:
26
+ // §5.1 — CWE_MAP, the primary sink-side mapping (keyed on `vuln.cwe`,
27
+ // 21 rows — 20 from D1, plus CWE-201 added by Sub-project H's
28
+ // AC-07 closure).
29
+ // §5.2 — the CWE-79 `framework` refinement: DOM/React sinks are
30
+ // `client-storage` (partial); every other framework is
31
+ // `http-response` (modeled).
32
+ // §5.3 — PRIVACY_CATEGORY_MAP, the privacy-catalog mapping (9 rows).
33
+ // `thirdPartySdk` is the one entry that DOES split at match time
34
+ // and cannot be resolved by a registry at all — see item 4 below.
35
+ // §6 — the `coverageStatus` decision procedure (modeled/partial/
36
+ // candidate/unsupported — never `manual`, §6.5).
37
+ // §7.1 — CATEGORY_NODE_KIND: sink-side node kind is category-derived,
38
+ // NOT uniformly `'sink'` (unlike D2's uniform `'source'`). A
39
+ // `null` category (`unsupported`) always yields `'process'`, and
40
+ // `kind === 'process'` iff `coverageStatus === 'unsupported'`
41
+ // (D1/3c's biconditional — contingent, per §7.1's own boxed note,
42
+ // on no AI-sink detection existing yet; not this task's problem
43
+ // to fix, but this module's own FR-203 branch below must not
44
+ // accidentally break it).
45
+ // §7.5 — CATEGORY_EXTERNALITY: category-derived, with the null-category
46
+ // (`unsupported`/`process`) fallback hardcoded to `'internal'` —
47
+ // the asymmetric OPPOSITE of source-registry.js's own
48
+ // unsupported fallback (`'unknown'`), per §7.5's own
49
+ // "2026-08-31 implementation note (D2, Task 1)" asymmetry #1: a
50
+ // sink's unsupported call site is still IN this program
51
+ // (genuinely internal); a source's unsupported construct is one
52
+ // we don't even understand (no honest internal claim to make).
53
+ // §9.0 — the shared `{kind, category, coverageStatus, externality,
54
+ // reason}` decision shape. `category` is this module's OWN field
55
+ // name — never a field literally called `subtype` (that's
56
+ // Sub-project E's graph builder's job, writing this decision's
57
+ // `category` onto `node.subtype`).
58
+ // §9's D3 checklist item 3 — the `unsupported` → `process` node with its
59
+ // reason string must be PRESERVED, never dropped (the single
60
+ // easiest way to violate AC-11/FR-201, per that item's own text).
61
+ // §9's D3 checklist item 4 — `thirdPartySdk` is a known, disclosed open
62
+ // item: resolves to `analytics` (the plurality of its receiver
63
+ // alternation) with `coverageStatus: 'partial'`, never a silent,
64
+ // undisclosed guess. A later match-time consumer that knows which
65
+ // receiver actually matched (segment/amplitude/mixpanel/posthog →
66
+ // analytics; sentry/datadog → monitoring; stripe → external-api;
67
+ // intercom/braze → collaboration) can promote `partial` →
68
+ // `modeled` — this registry cannot, since it reclassifies
69
+ // ENTRIES, never match-site receivers.
70
+ // §9's D3 checklist item 5 — FR-203: closed below. See the dedicated
71
+ // comment on `reclassifySink`'s `opts.destinationUnresolved`
72
+ // branch for the full reasoning, including how it is kept
73
+ // DISTINCT from the unrelated, already-shipped §16.7
74
+ // degraded-analysis `unresolved` case (a different module
75
+ // entirely — a future Sub-project E graph builder reading
76
+ // `path-store.js` diagnostics, never this registry).
77
+ // DISCLOSED ASYMMETRY: `reclassifyPrivacySink` has no equivalent
78
+ // `opts.destinationUnresolved` parameter. Item 5's own text names
79
+ // only `reclassifySink`; nothing in the design scopes FR-203 out
80
+ // of the privacy side, and the design's own worked examples
81
+ // (a fetch() call with a computed URL) map onto privacy
82
+ // categories like `outboundHttp`/`thirdPartySdk` just as
83
+ // plausibly as onto a general CATALOG sink. Left unresolved here,
84
+ // not silently — no consumer exists yet to need it (Sub-project E
85
+ // hasn't landed), so there was nothing to prove this signature
86
+ // against; closing it is deferred to whichever increment adds
87
+ // the first real caller.
88
+ //
89
+ // Pure reclassification layer: reads catalog entries as DATA and maps them
90
+ // onto the target vocabulary. It never re-derives what a call site
91
+ // matches — `matchSinkOrSanitizer()` / `matchPrivacySink()` already own
92
+ // that, and duplicating it here would fork a matcher the corpus proves
93
+ // against one that nothing proves (DESIGN_REGISTRIES.md §1).
94
+ //
95
+ // Isolation: imports ONLY `CATALOG` from ../dataflow/catalog.js and the
96
+ // privacy catalog's own exports from ../dataflow/privacy-catalog.js.
97
+ // Never dataflow/engine.js, never dataflow/summaries.js, never matcher
98
+ // internals (this PRD's §18.1 isolation principle, the same basis
99
+ // source-registry.js and Sub-project A/B/C use for their own `dataflow/`
100
+ // reuse).
101
+
102
+ import { CATALOG } from '../dataflow/catalog.js';
103
+ import { PRIVACY_SINK_CATALOG } from '../dataflow/privacy-catalog.js';
104
+ import { ORM_WRITE_CATALOG } from '../dataflow/orm-write-catalog.js';
105
+
106
+ // ─────────────────────────────────────────────────────────────────────────
107
+ // §5.1 — CWE_MAP: the primary sink-side mapping, keyed on the entry's
108
+ // `vuln.cwe` field (21 rows). A `null` category is NOT a dropped entry —
109
+ // see §6.4: it is retained with node kind `process` and a stated reason,
110
+ // which is what AC-11 / FR-201 require.
111
+ // ─────────────────────────────────────────────────────────────────────────
112
+
113
+ export const CWE_MAP = Object.freeze({
114
+ 'CWE-89': Object.freeze({ category: 'database', status: 'modeled', why: 'a SQL query call is unambiguously a database destination' }),
115
+ 'CWE-943': Object.freeze({ category: 'database', status: 'modeled', why: 'NoSQL $where — still a database destination' }),
116
+ 'CWE-22': Object.freeze({ category: 'file', status: 'modeled', why: 'path traversal sinks are filesystem reads/writes' }),
117
+ 'CWE-73': Object.freeze({ category: 'file', status: 'modeled', why: 'arbitrary file write' }),
118
+ 'CWE-918': Object.freeze({ category: 'external-api', status: 'modeled', why: 'SSRF sinks are outbound HTTP client calls' }),
119
+ 'CWE-601': Object.freeze({ category: 'http-response', status: 'modeled', why: 'a redirect is written as a response header' }),
120
+ 'CWE-113': Object.freeze({ category: 'http-response', status: 'modeled', why: 'response splitting — the sink IS the response header writer' }),
121
+ // The sink side's ONE documented refinement (§5.2). `status: 'split'` is
122
+ // an INTERNAL marker only — reclassifySink() intercepts it before it can
123
+ // ever reach a caller as a coverageStatus (mirrors PROVENANCE_MAP's own
124
+ // `agent-tool` row in source-registry.js).
125
+ 'CWE-79': Object.freeze({ category: null, status: 'split', why: "REFINED ON `framework`: browser-DOM sinks and server-side response writers are different destinations" }),
126
+ 'CWE-90': Object.freeze({ category: 'database', status: 'candidate', why: 'an LDAP directory is a queryable store, structurally like a DB, but FR-201 never names directory services' }),
127
+ // AC-07 closure (Sub-project H): the AI-sink catalog bridge. CWE-201
128
+ // (Insertion of Sensitive Information Into Sent Data) is the general
129
+ // CATALOG's own key for the four OpenAI/Anthropic/Bedrock sink entries.
130
+ // Deliberately NOT CWE-359 — that CWE belongs exclusively to
131
+ // PRIVACY_SINK_CATALOG's "Privacy Leak" family, and this file's own
132
+ // `completeness/1c` test fails the build if CWE_MAP ever maps it.
133
+ 'CWE-201': Object.freeze({ category: 'ai-model-provider', status: 'modeled', why: 'a call to a named AI model provider SDK (OpenAI/Anthropic/Bedrock) is unambiguously an AI-model-provider destination' }),
134
+ // Everything below: the data's destination is IN-PROCESS COMPUTATION (an
135
+ // interpreter, parser, template engine, regex engine, loader, or raw
136
+ // memory). FR-201's category list is an EGRESS taxonomy and models none
137
+ // of them — the single largest structural finding of D1 (§3/§7.1).
138
+ 'CWE-78': Object.freeze({ category: null, status: 'unsupported', why: 'shell/process execution — no FR-201 category models process execution' }),
139
+ 'CWE-95': Object.freeze({ category: null, status: 'unsupported', why: 'code evaluation (eval/Function/exec/compile) — destination is an interpreter' }),
140
+ 'CWE-94': Object.freeze({ category: null, status: 'unsupported', why: 'code injection / template compilation — destination is an interpreter or template engine' }),
141
+ 'CWE-1336': Object.freeze({ category: null, status: 'unsupported', why: 'SSTI — destination is a template engine, not a data destination' }),
142
+ 'CWE-502': Object.freeze({ category: null, status: 'unsupported', why: 'deserialization — destination is a deserializer' }),
143
+ 'CWE-611': Object.freeze({ category: null, status: 'unsupported', why: 'XXE — destination is an XML parser' }),
144
+ 'CWE-643': Object.freeze({ category: null, status: 'unsupported', why: 'XPath injection — destination is a query engine over an in-memory document' }),
145
+ 'CWE-120': Object.freeze({ category: null, status: 'unsupported', why: 'buffer overflow — destination is raw memory' }),
146
+ 'CWE-787': Object.freeze({ category: null, status: 'unsupported', why: 'out-of-bounds write — destination is raw memory' }),
147
+ 'CWE-1333': Object.freeze({ category: null, status: 'unsupported', why: 'ReDoS — destination is the regex engine' }),
148
+ 'CWE-114': Object.freeze({ category: null, status: 'unsupported', why: 'untrusted library load — destination is the dynamic loader' }),
149
+ });
150
+
151
+ // §5.2 — CWE-79's refinement key. FR-201 lumps "browser DOM or client
152
+ // storage" into one SINK_CATEGORIES value (`client-storage`), which
153
+ // correctly targets the PRD's own bullet but under-names the DOM half —
154
+ // disclosed via `status: 'partial'`, never silently upgraded to `modeled`
155
+ // (DESIGN_REGISTRIES.md §7.4(b); not this task's to patch).
156
+ export const DOM_FRAMEWORKS = new Set(['dom', 'react']);
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────
159
+ // §7.1 — CATEGORY_NODE_KIND: sink category → node kind. Every sink category
160
+ // any catalog can currently produce, plus the in-process fallback
161
+ // (`null` category → `'process'`, handled directly in the functions below,
162
+ // not via this table).
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+
165
+ export const CATEGORY_NODE_KIND = Object.freeze({
166
+ 'log': 'log', 'stdout': 'log',
167
+ 'http-response': 'sink',
168
+ 'database': 'store', 'file': 'store', 'object-storage': 'store',
169
+ 'cache': 'store', 'client-storage': 'store', 'backup': 'store', 'export': 'store',
170
+ 'queue': 'queue',
171
+ 'external-api': 'external', 'webhook': 'external', 'email': 'external',
172
+ 'sms': 'external', 'push-notification': 'external', 'collaboration': 'external',
173
+ 'analytics': 'external', 'monitoring': 'external',
174
+ 'ai-model-provider': 'external', 'ai-agent': 'external', 'ai-tool': 'external',
175
+ 'ai-vector-store': 'external', 'ai-memory': 'external', 'ai-training': 'external',
176
+ 'ai-evaluation': 'external', 'ai-telemetry': 'external',
177
+ 'ai-local-model': 'process',
178
+ 'declared': 'sink',
179
+ });
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────
182
+ // §7.5 — CATEGORY_EXTERNALITY: sink category → externality. Fully specified
183
+ // by D1 (unlike source-registry.js's SOURCE_CATEGORY_EXTERNALITY, which D2
184
+ // had to build fresh — see that module's own header for why). The
185
+ // null-category (`unsupported`/`process`) case is handled directly in the
186
+ // functions below as a hardcoded `'internal'`, not via this table — a
187
+ // sink's unsupported call site is still IN this program, genuinely
188
+ // internal, the asymmetric opposite of the source side's `'unknown'`.
189
+ // ─────────────────────────────────────────────────────────────────────────
190
+
191
+ export const CATEGORY_EXTERNALITY = Object.freeze({
192
+ 'log': 'internal', 'stdout': 'internal', 'http-response': 'internal',
193
+ 'file': 'internal', 'client-storage': 'internal',
194
+ 'database': 'unknown', 'object-storage': 'unknown', 'cache': 'unknown',
195
+ 'queue': 'unknown', 'backup': 'unknown', 'export': 'unknown',
196
+ 'external-api': 'external', 'webhook': 'external', 'email': 'external',
197
+ 'sms': 'external', 'push-notification': 'external', 'collaboration': 'external',
198
+ 'analytics': 'external', 'monitoring': 'external',
199
+ 'ai-model-provider': 'external', 'ai-agent': 'external', 'ai-tool': 'external',
200
+ 'ai-vector-store': 'external', 'ai-memory': 'external', 'ai-training': 'external',
201
+ 'ai-evaluation': 'external', 'ai-telemetry': 'external', 'ai-local-model': 'internal',
202
+ 'declared': 'unknown',
203
+ });
204
+
205
+ // ─────────────────────────────────────────────────────────────────────────
206
+ // §5.3 — PRIVACY_CATEGORY_MAP: the privacy-catalog mapping, keyed on the
207
+ // entry's `category` field (9 rows — confirmed against the live
208
+ // privacy-catalog.js's PRIVACY_SINK_CATEGORIES, NINE, not the eight the
209
+ // scoping doc originally assumed; see this module's own test suite's
210
+ // completeness guard).
211
+ // ─────────────────────────────────────────────────────────────────────────
212
+
213
+ export const PRIVACY_CATEGORY_MAP = Object.freeze({
214
+ 'log': Object.freeze({ category: 'log', status: 'modeled', why: 'console/logger calls' }),
215
+ 'response': Object.freeze({ category: 'http-response', status: 'modeled', why: 'res.send / res.json' }),
216
+ 'outboundHttp': Object.freeze({ category: 'external-api', status: 'modeled', why: 'fetch / axios.post' }),
217
+ 'fileWrite': Object.freeze({ category: 'file', status: 'modeled', why: 'fs.writeFile / writeFileSync' }),
218
+ 's3Upload': Object.freeze({ category: 'object-storage', status: 'modeled', why: 's3.putObject' }),
219
+ 'emailSend': Object.freeze({ category: 'email', status: 'modeled', why: 'nodemailer sendMail' }),
220
+ 'storage': Object.freeze({ category: 'database', status: 'modeled', why: 'both current entries are mongodb insertOne/insertMany with a collection|db|mongo receiver — the name is broader than its entries, but nothing splits TODAY (D1/4b, proven against real entries)' }),
221
+ 'queues': Object.freeze({ category: 'queue', status: 'modeled', why: "sqs sendMessage / sns|kafka publish; FR-201's `queue` covers 'queues, topics, streams, and event buses'" }),
222
+ // §9's D3 checklist item 4: a KNOWN, DISCLOSED open item, never a silent
223
+ // guess. The single receiverTypeIn alternation
224
+ // (stripe|sentry|datadog|segment|amplitude|mixpanel|posthog|braze|
225
+ // intercom|analytics) spans FOUR SINK_CATEGORIES values — analytics
226
+ // (segment/amplitude/mixpanel/posthog), monitoring (sentry/datadog),
227
+ // external-api (stripe), collaboration (intercom/braze) — and which one
228
+ // is correct is only decidable AT MATCH TIME, from the receiver that
229
+ // actually matched: information a registry reclassifying ENTRIES never
230
+ // sees. Resolves to `analytics` (the plurality) with `coverageStatus:
231
+ // 'partial'`, so the ambiguity is visible on every node this produces,
232
+ // never hidden behind a confident `modeled`. A later match-time consumer
233
+ // that knows which receiver fired can promote `partial` → `modeled`.
234
+ 'thirdPartySdk': Object.freeze({ category: 'analytics', status: 'partial', why: 'DISCLOSED OPEN ITEM, not a silent guess: the receiverTypeIn alternation also matches sentry|datadog (monitoring), stripe (external-api) and intercom|braze (collaboration) — the true category is only decidable at MATCH TIME from the matched receiver, which a registry reclassifying ENTRIES never sees. A match-time consumer that knows the matched receiver can promote this partial to modeled.' }),
235
+ });
236
+
237
+ /**
238
+ * Reclassify a single `kind: 'sink'` catalog entry (from CATALOG) into
239
+ * DataFlowGraph v1's vocabulary. Total: every entry gets a decision, none
240
+ * throws, none is silently dropped (§6). `kind` is category-derived, NOT
241
+ * uniformly `'sink'` (§7.1) — a `null` category (`unsupported`) always
242
+ * yields `'process'`, never dropped, always carrying a non-empty `reason`
243
+ * (§9's D3 item 3 / AC-11).
244
+ *
245
+ * @param {object} entry a CATALOG entry with `kind === 'sink'`
246
+ * @param {object} [opts]
247
+ * @param {boolean} [opts.destinationUnresolved] FR-203 (§9's D3 item 5):
248
+ * set by a FUTURE per-call-site caller (Sub-project E's graph builder —
249
+ * no catalog entry carries per-call-site destination information, §7.5,
250
+ * so this can never be true from CATALOG data alone today) when the
251
+ * RECOGNIZED sink's actual destination could not be statically resolved
252
+ * (`fetch(url)` with a computed `url`; an SDK client built from config).
253
+ * Ignored when the entry's own category is unmapped (`null`) — see the
254
+ * branch below for why.
255
+ * @param {string} [opts.blockingExpression] the expression that prevented
256
+ * resolution, named in the returned `reason` (FR-203 requires the
257
+ * evidence panel to show it).
258
+ * @returns {{kind: string, category: string|null, coverageStatus: string, externality: string, reason: string}}
259
+ */
260
+ export function reclassifySink(entry, opts = {}) {
261
+ opts = opts ?? {}; // guard an explicit `null` too, not just `undefined`
262
+ const cwe = entry.vuln?.cwe ?? '<<none>>';
263
+ let row = CWE_MAP[cwe];
264
+ if (!row) {
265
+ // Defensive fallback only — D1/1c-equivalent completeness guard below
266
+ // proves every real CATALOG sink CWE is mapped, so this is unreachable
267
+ // from live data. A future genuinely-new CWE must fail that guard
268
+ // loudly, not this function silently.
269
+ return {
270
+ kind: 'process', category: null, coverageStatus: 'unsupported',
271
+ externality: 'internal', reason: `unmapped CWE ${cwe}`,
272
+ };
273
+ }
274
+ if (row.status === 'split') {
275
+ // The single documented refinement on the sink side (§5.2).
276
+ row = DOM_FRAMEWORKS.has(entry.framework)
277
+ ? { category: 'client-storage', status: 'partial', why: "LOSSY: the destination is the rendered browser DOM; schema.js's `client-storage` is its encoding of FR-201's 'browser DOM or client storage' bullet, and under-names the DOM half" }
278
+ : { category: 'http-response', status: 'modeled', why: 'a server-side response writer (res.send / PrintWriter / echo / Fprintf)' };
279
+ }
280
+
281
+ const category = row.category;
282
+ const kind = category === null ? 'process' : (CATEGORY_NODE_KIND[category] ?? 'sink');
283
+ const externality = category === null ? 'internal' : (CATEGORY_EXTERNALITY[category] ?? 'unknown');
284
+ const base = { kind, category, coverageStatus: row.status, externality, reason: row.why };
285
+
286
+ // FR-203 (§9's D3 item 5) — CLOSED here, and kept structurally distinct
287
+ // from the UNRELATED, already-shipped §16.7 degraded-analysis
288
+ // `unresolved` case (DESIGN_PATH_PROVENANCE.md §16.7 Finding 2, carried
289
+ // forward as binding on Sub-project D; produced elsewhere entirely — a
290
+ // FUTURE Sub-project E graph builder reading path-store.js's
291
+ // `orphanedPeerSources`/context-cap-degraded diagnostics, never this
292
+ // registry). Both cases share `kind: 'unresolved'` and
293
+ // `externality: 'unknown'`, and it would be a real mistake to conflate
294
+ // them (item 5's own text warns against exactly this):
295
+ // - FR-203 (this branch): the CLASSIFICATION succeeded — the registry
296
+ // knows the sink's category from its CWE/framework — but a SPECIFIC
297
+ // call site's destination expression could not be resolved
298
+ // (`fetch(url)` with a computed `url`). `coverageStatus` is
299
+ // UNCHANGED from whatever the category mapping already assigned
300
+ // (`base.coverageStatus` above) — destination resolution is a
301
+ // DIFFERENT AXIS from classification confidence (this item's own
302
+ // opening sentence), so a `modeled` sink whose destination happens
303
+ // to be dynamic stays `modeled`.
304
+ // - §16.7 (NOT this branch, NOT this module): the ANALYSIS itself is
305
+ // incomplete — a truncation-terminal path node with no catalog entry
306
+ // behind it at all, hence `coverageStatus: 'partial'` UNCONDITIONALLY
307
+ // (§16.7's own text: "the analyzer genuinely observed this flow and
308
+ // lost only its continuation" — §6.2's lossy-but-sure shape, not a
309
+ // category-mapping carry-over, since there is no category mapping to
310
+ // carry over).
311
+ // Guarded ONLY when `category !== null`: an unsupported/process sink has
312
+ // no category to retain (`category retained` is FR-203's own explicit
313
+ // requirement, and there is nothing to retain here), and letting this
314
+ // branch fire on a null-category entry would silently break D1/3c's
315
+ // `kind === 'process'` iff `coverageStatus === 'unsupported'`
316
+ // biconditional (coverageStatus would stay `'unsupported'` while kind
317
+ // became `'unresolved'` instead of `'process'`) — a bug this task must
318
+ // not introduce, even though fixing the biconditional's own documented
319
+ // future fragility (§7.1's boxed note, AI-sink detection) is explicitly
320
+ // not this task's problem.
321
+ if (opts.destinationUnresolved && category !== null) {
322
+ return {
323
+ kind: 'unresolved',
324
+ category,
325
+ coverageStatus: row.status,
326
+ externality: 'unknown',
327
+ reason: opts.blockingExpression
328
+ ? `destination could not be statically resolved: ${opts.blockingExpression}`
329
+ : 'destination could not be statically resolved to a fixed value',
330
+ };
331
+ }
332
+
333
+ return base;
334
+ }
335
+
336
+ /**
337
+ * Reclassify a single privacy-catalog entry (from PRIVACY_SINK_CATALOG) into
338
+ * DataFlowGraph v1's vocabulary. A SEPARATE function from `reclassifySink`,
339
+ * deliberately — see this module's own header for why (§2.1: the two
340
+ * catalogs key on different fields, and privacy-catalog.js is deliberately
341
+ * not merged into CATALOG).
342
+ *
343
+ * @param {object} entry a PRIVACY_SINK_CATALOG entry
344
+ * @returns {{kind: string, category: string|null, coverageStatus: string, externality: string, reason: string}}
345
+ */
346
+ export function reclassifyPrivacySink(entry) {
347
+ const row = PRIVACY_CATEGORY_MAP[entry.category];
348
+ if (!row) {
349
+ // Defensive fallback only — the completeness guard in this module's own
350
+ // test suite proves every real privacy-catalog category is mapped
351
+ // (both against the entries actually used AND against the DECLARED
352
+ // PRIVACY_SINK_CATEGORIES vocabulary), so this is unreachable from live
353
+ // data.
354
+ return {
355
+ kind: 'process', category: null, coverageStatus: 'unsupported',
356
+ externality: 'internal', reason: `unmapped privacy category ${entry.category}`,
357
+ };
358
+ }
359
+ return {
360
+ kind: CATEGORY_NODE_KIND[row.category] ?? 'sink',
361
+ category: row.category,
362
+ coverageStatus: row.status,
363
+ externality: CATEGORY_EXTERNALITY[row.category] ?? 'unknown',
364
+ reason: row.why,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Reclassify a single `dataflow/orm-write-catalog.js` entry into
370
+ * DataFlowGraph v1's vocabulary. Milestone 2, Sub-project E, increment 1.
371
+ * A THIRD separate function, deliberately, for the identical reason
372
+ * `reclassifySink`/`reclassifyPrivacySink` are already two separate
373
+ * functions rather than one dispatcher (§2.1 above): `ORM_WRITE_CATALOG`
374
+ * keys on `category` exactly like the privacy catalog does, but it is its
375
+ * own isolated catalog (`orm-write-catalog.js`'s own header explains why),
376
+ * and mirroring `reclassifyPrivacySink`'s shape here — rather than
377
+ * threading a third catalog through that function — keeps each
378
+ * reclassifier reading exactly one catalog's own field convention.
379
+ *
380
+ * `coverageStatus` is the literal string `'candidate'`, UNCONDITIONALLY,
381
+ * computed from nothing — never derived from `entry` the way `reclassifySink`
382
+ * derives it from `CWE_MAP`, since this catalog carries no `vuln.cwe` that
383
+ * drives classification at all (its `vuln` block exists for documentation/
384
+ * potential-future-use only, mirroring `reclassifyPrivacySink`'s own
385
+ * "category drives reclassification, not the vuln block" pattern). Even
386
+ * with both of `orm-write-catalog.js`'s own precision signals satisfied,
387
+ * this is real, disclosed uncertainty — an arbitrary capitalized-identifier
388
+ * receiver could still be a non-ORM builder pattern that happens to take an
389
+ * object-literal argument — so this is deliberately never `'modeled'`.
390
+ *
391
+ * `kind`/`externality` are derived the SAME WAY `reclassifyPrivacySink`
392
+ * derives them (`CATEGORY_NODE_KIND`/`CATEGORY_EXTERNALITY`, keyed on
393
+ * `category: 'database'`) — genuinely mirroring that function's shape,
394
+ * not a hand-copied literal, so this stays correct if either table's
395
+ * `'database'` row is ever revised.
396
+ *
397
+ * @param {object} entry an ORM_WRITE_CATALOG entry
398
+ * @returns {{kind: string, category: string, coverageStatus: 'candidate', externality: string, reason: string}}
399
+ */
400
+ export function reclassifyOrmWrite(entry) {
401
+ const category = entry?.category ?? 'database';
402
+ return {
403
+ kind: CATEGORY_NODE_KIND[category] ?? 'sink',
404
+ category,
405
+ coverageStatus: 'candidate',
406
+ externality: CATEGORY_EXTERNALITY[category] ?? 'unknown',
407
+ reason: 'heuristic ORM-write recognition (create/save/update/upsert on a bare capitalized receiver with an object-literal first argument) — unconfirmed receiver identity, never full-confidence',
408
+ };
409
+ }
410
+
411
+ // Re-exported for callers that want the raw sink-entry slice without
412
+ // re-filtering CATALOG themselves (the completeness guards in this
413
+ // module's own test suite are the primary consumer) — mirrors
414
+ // source-registry.js's own SOURCE_ENTRIES precedent.
415
+ export const SINK_ENTRIES = Object.freeze(CATALOG.filter((e) => e.kind === 'sink'));
416
+
417
+ // Re-exported for the same reason, on the privacy side — callers that want
418
+ // the privacy sink entries without importing dataflow/privacy-catalog.js
419
+ // directly. Deliberately NOT a merge with SINK_ENTRIES (see this module's
420
+ // own header).
421
+ export const PRIVACY_SINK_ENTRIES = PRIVACY_SINK_CATALOG;
422
+
423
+ // Re-exported for the same reason, on the ORM-write side (Milestone 2,
424
+ // Sub-project E, increment 1) — callers that want the ORM-write sink
425
+ // entries without importing dataflow/orm-write-catalog.js directly.
426
+ // Deliberately NOT a merge with SINK_ENTRIES/PRIVACY_SINK_ENTRIES.
427
+ export const ORM_WRITE_ENTRIES = ORM_WRITE_CATALOG;