@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -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} +13 -4
  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/2271.index.js +165 -0
  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 +307 -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} +14 -6
  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} +14 -6
  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} +67 -3
  35. package/dist/5051.index.js +770 -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} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  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} +26 -8
  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} +580 -187
  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/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,397 @@
1
+ //
2
+ // transform-catalog.js — Data Flow Explorer, Sub-project D, increment D4.
3
+ //
4
+ // Recognizes that a CALL performs a security- or privacy-relevant data
5
+ // transformation, and classifies it into DataFlowGraph v1's §10.6
6
+ // vocabulary: `TRANSFORM_KINDS` × `REVERSIBILITY_VALUES` (`schema.js`).
7
+ //
8
+ // ─── What makes this module different from D2/D3 ────────────────────────
9
+ //
10
+ // `source-registry.js` (D2) and `sink-registry.js` (D3) are RECLASSIFICATION
11
+ // layers: they read `dataflow/catalog.js`'s already-proven, corpus-backed
12
+ // entries and map them onto a new vocabulary. This module has no such input.
13
+ // `DESIGN_REGISTRIES.md` §8.2/§8.3 measured why:
14
+ // - the 706 sanitizer entries are keyed on `effect` + a THREAT CLASS
15
+ // ("does this neutralize XSS"), not on "what kind of transformation is
16
+ // this and is it reversible" — reclassifying them would be a category
17
+ // error;
18
+ // - the one genuinely transform-shaped list that exists,
19
+ // `privacy-catalog.js`'s `PRIVACY_TRANSFORM_CALLEES`, is a private
20
+ // Set behind a BOOLEAN predicate (`isPrivacyTransformCallee`) that
21
+ // tells a caller *that* a callee is a transform but never *which kind*.
22
+ // So this catalog is written from scratch. §8.3 left the access problem
23
+ // open between "a lineage-side table of its own" and "export the Set from
24
+ // privacy-catalog.js"; this increment's plan settles it as the former —
25
+ // this module imports NOTHING from `scanner/src/dataflow/` at all (see
26
+ // Isolation below).
27
+ //
28
+ // ─── Isolation ──────────────────────────────────────────────────────────
29
+ //
30
+ // Imports ONLY `TRANSFORM_KINDS` / `REVERSIBILITY_VALUES` from ./schema.js.
31
+ // Never `dataflow/catalog.js`, never `dataflow/privacy-catalog.js`, never
32
+ // `dataflow/engine.js` / `dataflow/summaries.js` / any matcher internals
33
+ // (this PRD's §18.1 isolation principle, the same basis every Sub-project
34
+ // A–D module uses).
35
+ //
36
+ // ─── The scope boundary this module must NOT cross ──────────────────────
37
+ //
38
+ // PRD §10.6 lists nine things a Transformation entity must identify. This
39
+ // module produces FOUR of them, and it is a hard error for it to produce
40
+ // any of the rest:
41
+ //
42
+ // PRODUCED: kind; reversibility; algorithm (when the callee pattern
43
+ // itself names one — never guessed); evidence + confidence.
44
+ //
45
+ // NOT PRODUCED, because they need a CALL SITE and this module only ever
46
+ // sees a callee PATTERN: input/output access paths, function code
47
+ // location, "applies on all feasible paths or only some paths",
48
+ // key-management evidence.
49
+ //
50
+ // NOT PRODUCED, because Milestone 1's own binding scope decision forbids
51
+ // it: **"control credit granted or denied and the reason."** The parent
52
+ // scoping doc's Decision 2 reads: *"Sub-project D (transformation-kind
53
+ // recognition) is in scope for Milestone 1; awarding verdict 'credit'
54
+ // from that recognition is explicitly out of scope and stays
55
+ // `not_assessed` until Milestone 2."* Recognizing that a `mask` happened
56
+ // is this module's job. Deciding whether that `mask` earns "protected"
57
+ // is `protection.js`'s `aggregateVerdicts()` — Milestone 2's FR-401-405
58
+ // analyzers, reading this module's output. If you find yourself adding a
59
+ // field that answers "is this transform good enough", stop.
60
+ //
61
+ // A future Sub-project E graph builder is what calls this once per real
62
+ // call site and combines the answer with location/path data to build an
63
+ // actual `Transformation` entity.
64
+ //
65
+ // ─── §10.6's "never synonyms" rule ──────────────────────────────────────
66
+ //
67
+ // *"Masking, hashing, tokenization, and encryption must never be treated
68
+ // as synonyms."* Each is its own kind here, with its own reversibility,
69
+ // and no entry may collapse two of them:
70
+ // mask → irreversible (information is destroyed);
71
+ // hash → irreversible (one-way by construction);
72
+ // tokenize → REVERSIBLE (a token is designed to be detokenized by the
73
+ // vault that issued it — this is precisely the property that
74
+ // makes it not-a-mask);
75
+ // encrypt → REVERSIBLE (designed to be decrypted).
76
+ //
77
+ // ─── Curated, not exhaustive (and the confidence rule) ──────────────────
78
+ //
79
+ // This is new detection capability, so it favors PRECISION over recall. A
80
+ // callee this catalog does not recognize returns `null` — never a guess.
81
+ //
82
+ // confidence: 'high' — the pattern identifies both the callee AND its
83
+ // transformation semantics unambiguously: a
84
+ // qualified library/module API (`crypto.createHash`,
85
+ // `bcrypt.hash`, `hashlib.sha256`) or a platform
86
+ // global whose name is effectively reserved
87
+ // (`encodeURIComponent`, `btoa`).
88
+ // confidence: 'medium' — EITHER the name is unqualified and an
89
+ // application could define it with unrelated
90
+ // semantics (`maskCard`, `tokenize`, `normalize`),
91
+ // OR the API is genuinely general-purpose and its
92
+ // per-call-site semantics vary (`_.truncate` is
93
+ // string shortening, not necessarily a privacy
94
+ // control).
95
+ //
96
+ // There is no 'low' tier: a pattern that would only justify 'low' does not
97
+ // belong in a curated catalog at all — it belongs in the disclosed-gap
98
+ // list below.
99
+ //
100
+ // ─── Deliberate gaps (disclosed, per this PRD's own convention) ─────────
101
+ //
102
+ // 1. `custom` and `unknown` are NEVER emitted. They are a consumer's
103
+ // fallback vocabulary for a transform-shaped call this catalog does not
104
+ // recognize; the honest answer from a RECOGNIZER is `null`. Whoever
105
+ // must still materialize a Transformation entity for an unrecognized
106
+ // call is the one who chooses between `custom` and `unknown`.
107
+ // 2. `anonymize*` / `pseudonymize*` are deliberately EXCLUDED even though
108
+ // both are real, common names (and both are in `privacy-catalog.js`'s
109
+ // own private Set). Neither maps onto a single `TRANSFORM_KINDS` value:
110
+ // anonymization is variously masking, redaction or aggregation, and
111
+ // pseudonymization is variously tokenization or keyed hashing. Forcing
112
+ // either into one kind is exactly the synonym-collapse §10.6 forbids.
113
+ // 3. `aggregate` and `tokenize` are the THINNEST kinds here — one
114
+ // naming-convention entry each. Aggregation is normally a data-shape
115
+ // operation (`.reduce()`, `.groupBy()`) with no security-relevant
116
+ // callee to key on, and payment/PII tokenization vaults are
117
+ // overwhelmingly vendor-proprietary, so no non-invented library entry
118
+ // was available. `tokenize*` additionally collides with NLP/lexer
119
+ // tokenization (`tokenize(sourceText)`), which is a real transformation
120
+ // but not this one — hence 'medium' and an evidence string that says so.
121
+ // 4. Serialization/deserialization (`JSON.stringify`, `JSON.parse`) is
122
+ // NOT cataloged as `encode`/`decode`. `MAPPING_TYPES` in `schema.js`
123
+ // already has `serialization`/`deserialization` as their own values —
124
+ // they are an edge's mapping type, not a transformation kind.
125
+ // 5. Argument-borne facts are never read. `crypto.createHash('sha256')`
126
+ // yields `algorithm: null` here, because this module classifies a
127
+ // CALLEE pattern and never sees a call site's arguments. A future
128
+ // graph builder with the call site in hand can refine it.
129
+ // 6. `encodeURIComponent` is ALSO a sanitizer in `dataflow/catalog.js`.
130
+ // That is not a conflict: "neutralizes XSS" and "is an `encode`
131
+ // transformation" are two different axes for two different consumers.
132
+ // This module never imports or consults that classification.
133
+ // 7. Factory attribution (task review M1a): `crypto.createHash`/
134
+ // `createCipheriv`/`createHmac`, and Java's `MessageDigest.getInstance`,
135
+ // classify the FACTORY call, not the later call where the data actually
136
+ // flows (`h.update(pii); h.digest()`). Sub-project E — the first real
137
+ // consumer of this module, which attaches access paths and code
138
+ // location to build a real Transformation entity — must not assume the
139
+ // factory call site is where the input/output paths live; it is only
140
+ // the call site that names WHICH transformation is about to happen.
141
+ // 8. Language scope (task review M1b): the `py`/`java` entries (9 of 42)
142
+ // are ahead of this engine's current JS/TS-only scope (Sub-project D's
143
+ // own scoping doc explicitly excludes any language beyond JS/TS) and
144
+ // cannot fire against anything this codebase parses today. Kept,
145
+ // disclosed rather than removed, since each carries a distinctive
146
+ // qualified receiver (`hashlib.*`, `base64.*`,
147
+ // `MessageDigest.getInstance`) with no plausible JS collision risk.
148
+ // 9. The `language` field (task review M1c) is DOCUMENTARY ONLY —
149
+ // `_entryMatches` never reads it. Matching is language-agnostic by
150
+ // construction; a caller must not assume passing a `language` hint
151
+ // narrows which entries can match, because none does.
152
+ //
153
+ // ─── Entry shape ────────────────────────────────────────────────────────
154
+ //
155
+ // Structurally parallel to `dataflow/catalog.js` (one entry per logical
156
+ // row, with `id` / `language` / `match`) for a future reader's
157
+ // familiarity — but it is NOT that catalog's data and never reads it.
158
+ //
159
+ // { id, language, // documentary only — see gap 9 above, never read by matching
160
+ // kind, reversibility, algorithm, confidence,
161
+ // match: { type: 'call', callee } // bare/global call
162
+ // | { type: 'member-call', object, method } // `object.method()`
163
+ // | { type: 'name-pattern', pattern, notObjects? },
164
+ // evidence, // '{name}' is replaced with the matched callee text
165
+ // examples } // every entry is proven matchable by a real test
166
+ //
167
+
168
+ import { TRANSFORM_KINDS, REVERSIBILITY_VALUES } from './schema.js';
169
+
170
+ // Receivers that mean "this is still the global builtin" for a `call`
171
+ // entry — `window.btoa(x)` is `btoa(x)`.
172
+ const GLOBAL_RECEIVERS = Object.freeze(['window', 'globalThis', 'global', 'self']);
173
+
174
+ // A naming-convention pattern matches `verb`, `verbSomething`, `verb_something`
175
+ // and `verb2` — but never `verbed`/`verbing`/`verbal`, and never a name that
176
+ // merely CONTAINS the verb (`applyMask` is a bitmask helper, not a mask
177
+ // transform — `privacy-catalog.js`'s own comment records that same trap).
178
+ const namingConvention = (verb) => new RegExp(`^${verb}(?=[A-Z_0-9]|$)`);
179
+
180
+ export const TRANSFORM_CATALOG = Object.freeze([
181
+ // ─── hash — irreversible by construction ───────────────────────────────
182
+ { id: 'js-node-create-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createHash' }, evidence: "call to '{name}' — Node's built-in cryptographic hash factory (the digest algorithm is an argument, not read here)", examples: ['crypto.createHash'] },
183
+ { id: 'js-node-create-hmac', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createHmac' }, evidence: "call to '{name}' — Node's built-in keyed-MAC factory; a keyed hash, still one-way (the digest algorithm is an argument, not read here)", examples: ['crypto.createHmac'] },
184
+ { id: 'js-node-pbkdf2', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'pbkdf2', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'pbkdf2' }, evidence: "call to '{name}' — Node's built-in PBKDF2 key-derivation function", examples: ['crypto.pbkdf2'] },
185
+ { id: 'js-node-pbkdf2-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'pbkdf2', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'pbkdf2Sync' }, evidence: "call to '{name}' — Node's built-in PBKDF2 key-derivation function (sync form)", examples: ['crypto.pbkdf2Sync'] },
186
+ { id: 'js-node-scrypt', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'scrypt', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'scrypt' }, evidence: "call to '{name}' — Node's built-in scrypt key-derivation function", examples: ['crypto.scrypt'] },
187
+ { id: 'js-node-scrypt-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'scrypt', confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'scryptSync' }, evidence: "call to '{name}' — Node's built-in scrypt key-derivation function (sync form)", examples: ['crypto.scryptSync'] },
188
+ { id: 'js-webcrypto-digest', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'digest' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.digest (the digest algorithm is an argument, not read here)", examples: ['crypto.subtle.digest'] },
189
+ { id: 'js-bcrypt-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'bcrypt', method: 'hash' }, evidence: "call to '{name}' — the bcrypt password-hashing library", examples: ['bcrypt.hash'] },
190
+ { id: 'js-bcrypt-hash-sync', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'bcrypt', confidence: 'high', match: { type: 'member-call', object: 'bcrypt', method: 'hashSync' }, evidence: "call to '{name}' — the bcrypt password-hashing library (sync form)", examples: ['bcrypt.hashSync'] },
191
+ { id: 'js-argon2-hash', language: 'js', kind: 'hash', reversibility: 'irreversible', algorithm: 'argon2', confidence: 'high', match: { type: 'member-call', object: 'argon2', method: 'hash' }, evidence: "call to '{name}' — the argon2 password-hashing library", examples: ['argon2.hash'] },
192
+ { id: 'py-hashlib-sha256', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha256', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha256' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm", examples: ['hashlib.sha256'] },
193
+ { id: 'py-hashlib-sha512', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha512', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha512' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm", examples: ['hashlib.sha512'] },
194
+ { id: 'py-hashlib-sha1', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'sha1', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'sha1' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: SHA-1 is broken for collision resistance)", examples: ['hashlib.sha1'] },
195
+ { id: 'py-hashlib-md5', language: 'py', kind: 'hash', reversibility: 'irreversible', algorithm: 'md5', confidence: 'high', match: { type: 'member-call', object: 'hashlib', method: 'md5' }, evidence: "call to '{name}' — Python's stdlib hashlib; the callee name itself states the digest algorithm (recognized, not endorsed: MD5 is broken)", examples: ['hashlib.md5'] },
196
+ { id: 'java-message-digest', language: 'java', kind: 'hash', reversibility: 'irreversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'MessageDigest', method: 'getInstance' }, evidence: "call to '{name}' — the JCA MessageDigest factory (the digest algorithm is an argument, not read here)", examples: ['MessageDigest.getInstance'] },
197
+
198
+ // ─── encrypt — reversible by design ────────────────────────────────────
199
+ { id: 'js-node-cipheriv', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric cipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createCipheriv'] },
200
+ { id: 'js-node-cipher-legacy', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createCipher' }, evidence: "call to '{name}' — Node's deprecated symmetric cipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createCipher'] },
201
+ { id: 'js-node-public-encrypt', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'publicEncrypt' }, evidence: "call to '{name}' — Node's built-in public-key encryption (the key and padding are arguments, not read here)", examples: ['crypto.publicEncrypt'] },
202
+ { id: 'js-webcrypto-encrypt', language: 'js', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'encrypt' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.encrypt (the cipher suite is an argument, not read here)", examples: ['crypto.subtle.encrypt'] },
203
+ { id: 'app-encrypt-naming', language: '*', kind: 'encrypt', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('encrypt') }, evidence: "callee name '{name}' matches the `encrypt*` naming convention (medium: an application-defined name, not a library API — the cipher and key management are unknown)", examples: ['encrypt', 'encryptCardNumber', 'vault.encryptField'] },
204
+
205
+ // ─── decrypt — the direct counterparts ─────────────────────────────────
206
+ { id: 'js-node-decipheriv', language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createDecipheriv' }, evidence: "call to '{name}' — Node's built-in symmetric decipher factory (the cipher suite is an argument, not read here)", examples: ['crypto.createDecipheriv'] },
207
+ { id: 'js-node-decipher-legacy',language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'createDecipher' }, evidence: "call to '{name}' — Node's deprecated symmetric decipher factory (removed in Node 22; still present in legacy code)", examples: ['crypto.createDecipher'] },
208
+ { id: 'js-node-private-decrypt',language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'crypto', method: 'privateDecrypt' }, evidence: "call to '{name}' — Node's built-in private-key decryption (the key and padding are arguments, not read here)", examples: ['crypto.privateDecrypt'] },
209
+ { id: 'js-webcrypto-decrypt', language: 'js', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'member-call', object: 'subtle', method: 'decrypt' }, evidence: "call to '{name}' — the Web Crypto API's SubtleCrypto.decrypt (the cipher suite is an argument, not read here)", examples: ['crypto.subtle.decrypt'] },
210
+ { id: 'app-decrypt-naming', language: '*', kind: 'decrypt', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('decrypt') }, evidence: "callee name '{name}' matches the `decrypt*` naming convention (medium: an application-defined name, not a library API)", examples: ['decrypt', 'decryptCardNumber', 'vault.decryptField'] },
211
+
212
+ // ─── encode — reversible ───────────────────────────────────────────────
213
+ { id: 'js-encode-uri-component',language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'encodeURIComponent' }, evidence: "call to '{name}' — the ECMAScript global URI-component encoder (classified here on the TRANSFORMATION axis; its separate role as an XSS sanitizer is a different axis this module never consults)", examples: ['encodeURIComponent', 'window.encodeURIComponent'] },
214
+ { id: 'js-encode-uri', language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'encodeURI' }, evidence: "call to '{name}' — the ECMAScript global URI encoder", examples: ['encodeURI'] },
215
+ { id: 'js-btoa', language: 'js', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'call', callee: 'btoa' }, evidence: "call to '{name}' — the platform global base64 encoder (base64 by specification, so the algorithm is statically knowable from the callee alone)", examples: ['btoa', 'window.btoa'] },
216
+ { id: 'py-b64encode', language: 'py', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'b64encode' }, evidence: "call to '{name}' — Python's stdlib base64 encoder", examples: ['base64.b64encode'] },
217
+ { id: 'py-b64encode-urlsafe', language: 'py', kind: 'encode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'urlsafe_b64encode' }, evidence: "call to '{name}' — Python's stdlib URL-safe base64 encoder", examples: ['base64.urlsafe_b64encode'] },
218
+
219
+ // ─── decode — the direct counterparts ──────────────────────────────────
220
+ { id: 'js-decode-uri-component',language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'decodeURIComponent' }, evidence: "call to '{name}' — the ECMAScript global URI-component decoder", examples: ['decodeURIComponent', 'window.decodeURIComponent'] },
221
+ { id: 'js-decode-uri', language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: null, confidence: 'high', match: { type: 'call', callee: 'decodeURI' }, evidence: "call to '{name}' — the ECMAScript global URI decoder", examples: ['decodeURI'] },
222
+ { id: 'js-atob', language: 'js', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'call', callee: 'atob' }, evidence: "call to '{name}' — the platform global base64 decoder (base64 by specification, so the algorithm is statically knowable from the callee alone)", examples: ['atob', 'window.atob'] },
223
+ { id: 'py-b64decode', language: 'py', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'b64decode' }, evidence: "call to '{name}' — Python's stdlib base64 decoder", examples: ['base64.b64decode'] },
224
+ { id: 'py-b64decode-urlsafe', language: 'py', kind: 'decode', reversibility: 'reversible', algorithm: 'base64', confidence: 'high', match: { type: 'member-call', object: 'base64', method: 'urlsafe_b64decode' }, evidence: "call to '{name}' — Python's stdlib URL-safe base64 decoder", examples: ['base64.urlsafe_b64decode'] },
225
+
226
+ // ─── mask — irreversible; naming convention only, by measurement ───────
227
+ // Masking has no canonical library the way hashing does: it is written
228
+ // per-application (`maskCard`, `maskSSN`, `maskEmail`). The convention IS
229
+ // the only reliable signal, so this kind tops out at 'medium'.
230
+ { id: 'app-mask-naming', language: '*', kind: 'mask', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('mask') }, evidence: "callee name '{name}' matches the app-level `mask*` naming convention (medium: no canonical masking library exists to key on, and a same-named application function could be e.g. a bitmask helper)", examples: ['maskCard', 'maskSSN', 'pii.maskEmail'] },
231
+
232
+ // ─── redact — irreversible; naming convention only ─────────────────────
233
+ { id: 'app-redact-naming', language: '*', kind: 'redact', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('redact') }, evidence: "callee name '{name}' matches the app-level `redact*` naming convention (medium: the well-known log redactors are configured declaratively, not called by a stable name, so there is no library API to key on)", examples: ['redact', 'redactSecrets', 'log.redactFields'] },
234
+
235
+ // ─── tokenize — REVERSIBLE, and deliberately not a mask ────────────────
236
+ { id: 'app-tokenize-naming', language: '*', kind: 'tokenize', reversibility: 'reversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('tokenize') }, evidence: "callee name '{name}' matches the `tokenize*` naming convention (medium: tokenization vaults are overwhelmingly vendor-proprietary, so no library API was available to key on; and this name also denotes NLP/lexer tokenization, a different transformation entirely)", examples: ['tokenize', 'tokenizeCard', 'vault.tokenizePAN'] },
237
+
238
+ // ─── aggregate — irreversible (per-record detail is discarded) ─────────
239
+ { id: 'app-aggregate-naming', language: '*', kind: 'aggregate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('aggregate') }, evidence: "callee name '{name}' matches the `aggregate*` naming convention (medium: this is the thinnest kind in the catalog — aggregation is normally an unnamed data-shape operation like `.reduce()`/`.groupBy()` with no callee to key on)", examples: ['aggregate', 'aggregateByRegion', 'collection.aggregate'] },
240
+
241
+ // ─── truncate — irreversible ───────────────────────────────────────────
242
+ { id: 'js-lodash-truncate', language: 'js', kind: 'truncate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'member-call', object: '_', method: 'truncate' }, evidence: "call to '{name}' — lodash's string truncation (medium not because the callee is ambiguous but because the API is general-purpose: it is not necessarily applied as a privacy control)", examples: ['_.truncate'] },
243
+ // `notObjects` keeps a real, unrelated Node API out: `fs.truncate` /
244
+ // `fsPromises.truncate` shorten a FILE, they do not transform a value.
245
+ { id: 'app-truncate-naming', language: '*', kind: 'truncate', reversibility: 'irreversible', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('truncate'), notObjects: ['fs', 'fsPromises', 'fsp'] }, evidence: "callee name '{name}' matches the `truncate*` naming convention (medium: general-purpose shortening, not necessarily a privacy control; `fs.truncate`, which shortens a file rather than a value, is excluded)", examples: ['truncate', 'truncateEmail', 'text.truncateTo'] },
246
+
247
+ // ─── normalize — reversibility genuinely UNKNOWN ───────────────────────
248
+ // Unicode normalization can be lossless (NFC/NFD round-trip) or lossy
249
+ // (NFKC/NFKD), path normalization discards `..` segments, and email
250
+ // normalization lowercases and strips tags. The callee alone cannot say
251
+ // which — so `unknown` is the honest value, not an overclaim in either
252
+ // direction.
253
+ { id: 'app-normalize-naming', language: '*', kind: 'normalize', reversibility: 'unknown', algorithm: null, confidence: 'medium', match: { type: 'name-pattern', pattern: namingConvention('normalize') }, evidence: "callee name '{name}' matches the `normalize*` convention, which the ecosystem uses consistently for normalization (String.prototype.normalize, path.normalize, validator.normalizeEmail) — medium because this name-level match cannot tell the ECMAScript builtin from a same-named application function, and the normalization FORM (lossy or lossless) is never knowable from the callee", examples: ['normalize', 'normalizeEmail', 'path.normalize', 'validator.normalizeEmail'] },
254
+ ]);
255
+
256
+ /**
257
+ * Split a callee descriptor into the pieces the matchers need.
258
+ * Returns `null` for anything malformed — a caller that could not fully
259
+ * resolve a callee may legitimately hand over a partial descriptor, and
260
+ * that is "no match", never a crash.
261
+ */
262
+ function _normalizeDescriptor(descriptor) {
263
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) return null;
264
+
265
+ if (descriptor.type === 'call') {
266
+ const { callee } = descriptor;
267
+ if (typeof callee !== 'string' || callee.trim() === '') return null;
268
+ const segs = callee.split('.').filter(Boolean);
269
+ if (segs.length === 0) return null;
270
+ return {
271
+ name: segs[segs.length - 1],
272
+ object: segs.length >= 2 ? segs[segs.length - 2] : null,
273
+ display: callee,
274
+ bare: segs.length === 1,
275
+ };
276
+ }
277
+
278
+ if (descriptor.type === 'member-call') {
279
+ const { object, method } = descriptor;
280
+ if (typeof object !== 'string' || object.trim() === '') return null;
281
+ if (typeof method !== 'string' || method.trim() === '') return null;
282
+ const objSegs = object.split('.').filter(Boolean);
283
+ if (objSegs.length === 0) return null;
284
+ return {
285
+ name: method,
286
+ object: objSegs[objSegs.length - 1],
287
+ display: `${object}.${method}`,
288
+ bare: false,
289
+ };
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ function _entryMatches(entry, c) {
296
+ const m = entry.match;
297
+ if (m.type === 'call') {
298
+ if (c.name !== m.callee) return false;
299
+ return c.bare || GLOBAL_RECEIVERS.includes(c.object);
300
+ }
301
+ if (m.type === 'member-call') {
302
+ return c.object === m.object && c.name === m.method;
303
+ }
304
+ if (m.type === 'name-pattern') {
305
+ if (!m.pattern.test(c.name)) return false;
306
+ if (m.notObjects && c.object && m.notObjects.includes(c.object)) return false;
307
+ return true;
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /**
313
+ * Classify a callee pattern as a §10.6 data transformation.
314
+ *
315
+ * @param {{type: 'call', callee: string}
316
+ * |{type: 'member-call', object: string, method: string}} calleeDescriptor
317
+ * A bare call (`maskCard(x)` → `{type:'call', callee:'maskCard'}`) or a
318
+ * method call (`crypto.createHash('sha256')` →
319
+ * `{type:'member-call', object:'crypto', method:'createHash'}`). A dotted
320
+ * `callee` string (`'crypto.createHash'`) is accepted too and resolves
321
+ * identically. Anything else — including `undefined`, a partial shape or
322
+ * an unrecognized `type` — returns `null` rather than throwing.
323
+ *
324
+ * @returns {{kind: string, reversibility: string, algorithm: string|null,
325
+ * confidence: 'high'|'medium', evidence: string}|null}
326
+ * `null` when this catalog recognizes no transformation. `kind` is always
327
+ * a `TRANSFORM_KINDS` value and `reversibility` always a
328
+ * `REVERSIBILITY_VALUES` value. There is deliberately NO control-credit
329
+ * field — see this file's header.
330
+ */
331
+ export function recognizeTransformation(calleeDescriptor) {
332
+ const c = _normalizeDescriptor(calleeDescriptor);
333
+ if (!c) return null;
334
+
335
+ // First match wins. Exact `call`/`member-call` entries are listed ahead
336
+ // of the naming-convention entries for each kind, so a library API can
337
+ // never be reported as a weaker naming-convention match.
338
+ for (const entry of TRANSFORM_CATALOG) {
339
+ if (!_entryMatches(entry, c)) continue;
340
+ return {
341
+ kind: entry.kind,
342
+ reversibility: entry.reversibility,
343
+ algorithm: entry.algorithm,
344
+ confidence: entry.confidence,
345
+ evidence: entry.evidence.replace('{name}', c.display),
346
+ };
347
+ }
348
+ return null;
349
+ }
350
+
351
+ /**
352
+ * The exact key set of a non-null `recognizeTransformation` result.
353
+ * Exported so a consumer — and this module's own tests — can assert the
354
+ * shape without hand-copying it, and so Decision 2's boundary (no
355
+ * control-credit field, ever) is enforceable structurally rather than by
356
+ * reading prose.
357
+ */
358
+ export const TRANSFORM_DECISION_KEYS = Object.freeze([
359
+ 'kind', 'reversibility', 'algorithm', 'confidence', 'evidence',
360
+ ]);
361
+
362
+ /** Confidence tiers this catalog can emit. There is deliberately no 'low'. */
363
+ export const TRANSFORM_CONFIDENCE_VALUES = Object.freeze(['high', 'medium']);
364
+
365
+ /**
366
+ * The `TRANSFORM_KINDS` values this catalog deliberately never emits — a
367
+ * recognizer's honest answer for an unrecognized callee is `null`, and
368
+ * choosing between `custom` and `unknown` belongs to whoever must still
369
+ * materialize a Transformation entity. Kept as data (not prose alone) so a
370
+ * test can pin it, per this package's disclose-the-gap convention.
371
+ */
372
+ export const NEVER_EMITTED_KINDS = Object.freeze(['custom', 'unknown']);
373
+
374
+ // ─────────────────────────────────────────────────────────────────────────
375
+ // Load-time integrity check. This is the ONLY use of the two schema.js
376
+ // imports, and it is deliberate: a catalog that hardcodes enum strings
377
+ // without ever referencing the source of truth is exactly the silent-drift
378
+ // hazard this package's own conventions warn about. A failure here is a
379
+ // programming error in THIS file (never bad caller input), so failing at
380
+ // import is correct — a catalog entry carrying a value outside
381
+ // `TRANSFORM_KINDS`/`REVERSIBILITY_VALUES` would otherwise reach a
382
+ // DataFlowGraph v1 Transformation entity and validate nowhere.
383
+ // ─────────────────────────────────────────────────────────────────────────
384
+ for (const entry of TRANSFORM_CATALOG) {
385
+ Object.freeze(entry);
386
+ Object.freeze(entry.match);
387
+ Object.freeze(entry.examples);
388
+ if (!TRANSFORM_KINDS.includes(entry.kind)) {
389
+ throw new Error(`transform-catalog.js: entry '${entry.id}' has kind '${entry.kind}', which is not in TRANSFORM_KINDS`);
390
+ }
391
+ if (NEVER_EMITTED_KINDS.includes(entry.kind)) {
392
+ throw new Error(`transform-catalog.js: entry '${entry.id}' emits '${entry.kind}', which this catalog must never emit (return null instead)`);
393
+ }
394
+ if (!REVERSIBILITY_VALUES.includes(entry.reversibility)) {
395
+ throw new Error(`transform-catalog.js: entry '${entry.id}' has reversibility '${entry.reversibility}', which is not in REVERSIBILITY_VALUES`);
396
+ }
397
+ }
@@ -0,0 +1,150 @@
1
+ //
2
+ // transit-protection.js — Milestone 2, Sub-project B ("transit protection
3
+ // analyzer", FR-401), increments 1 (plumbing skeleton) and 2 (verdict
4
+ // computation).
5
+ //
6
+ // Increment 1 shipped the ONLY thing it needed: running the already-shipped
7
+ // `crypto-protocol.js` whole-file/whole-repo TLS/cipher detector over every
8
+ // file's raw source text and returning a real, inspectable
9
+ // `Map<file, findings[]>`.
10
+ //
11
+ // Increment 2 (this addition) adds the real per-site verdict function,
12
+ // `resolveTransitProtectionForSite(site, transitEvidenceByFile)` — the
13
+ // `opts.resolveTransitProtection` hook `graph-builder.js` now composes into
14
+ // `edge.protection.transit`. See DESIGN_TRANSIT_PROTECTION.md's new §6 for
15
+ // the corrected hook point, the window constant, the `external-api`-only
16
+ // filter, and the decision table this function implements.
17
+ //
18
+ // Reuse boundary: this module imports ONLY `scanCryptoProtocol` from
19
+ // `../sast/crypto-protocol.js` — never `dataflow/engine.js`, never any
20
+ // other `src/lineage/` module. It still does NOT decide which graph EDGE a
21
+ // finding belongs to on its own — `resolveTransitProtectionForSite` takes a
22
+ // `site` (the same per-call-site object `graph-builder.js`'s edge-
23
+ // construction loop already has in scope) and a pre-computed
24
+ // `Map<file, findings[]>`, so the correlation itself is a pure, local
25
+ // (file, line)-proximity check, never a re-scan.
26
+ //
27
+ // `scanCryptoProtocol` itself already degrades gracefully and for free:
28
+ // `AGENTIC_SECURITY_NO_CRYPTO_PROTO=1` disables it (returns `[]`), and it
29
+ // silently returns `[]` for a file over 500KB or with no crypto-relevant
30
+ // content (`_isCryptoRelevant`) — both inherited here, not re-implemented.
31
+ //
32
+
33
+ import { scanCryptoProtocol } from '../sast/crypto-protocol.js';
34
+
35
+ // Increment 2 (DESIGN_TRANSIT_PROTECTION.md §6): a disclosed, deliberately
36
+ // NOT-calibrated starting value — no real fixture corpus exists yet to tune
37
+ // this against. A TLS-config object (`{ rejectUnauthorized: false }`) is
38
+ // often on the same line as, or a few lines before, the network call it
39
+ // configures; 10 lines is a generous but bounded window, following this
40
+ // package's own established line-window correlation precedent (measured
41
+ // directly: `engine.js`'s `dropGuardedFindings` and several other
42
+ // detectors use windows like `-2/+3`, `-2/+4`, `+10`, each independently
43
+ // chosen for its own correlation) — this constant is a NEW, independently
44
+ // chosen value for THIS correlation, not copied from any one of those.
45
+ export const TRANSIT_PROTECTION_WINDOW_LINES = 10;
46
+
47
+ // The two crypto-protocol.js families this increment's correlation cares
48
+ // about — confirmed against that module's own `EMITS` export and its
49
+ // `detectTlsNoVerify`/`detectTlsMinVersion` functions' own `_shape(...)`
50
+ // calls (`family: 'crypto-tls-no-verify'` / `family: 'crypto-tls-version'`).
51
+ const TRANSIT_FINDING_FAMILIES = new Set(['crypto-tls-no-verify', 'crypto-tls-version']);
52
+
53
+ /**
54
+ * Increment 2's real verdict function — the `opts.resolveTransitProtection`
55
+ * hook `graph-builder.js` (via `coverage.js`'s default wiring) applies once
56
+ * per site, at the exact edge-construction point that already reads
57
+ * `site.destination`. `site` is one entry from `enumerateSinkSites`'s
58
+ * `sites[]` (post any `opts.resolveSiteDecision`/`opts.resolveDestination`
59
+ * overrides — this function reads `site.decision`/`site.destination`, both
60
+ * already resolved by the time it runs). `transitEvidenceByFile` is the
61
+ * SAME `Map<file, findings[]>` `scanTransitEvidence` returns — computed
62
+ * exactly once, by the caller (`index.js`), never re-derived here from raw
63
+ * file text.
64
+ *
65
+ * A deliberate, narrow first slice (DESIGN_TRANSIT_PROTECTION.md §6):
66
+ * `category !== 'external-api'` returns `undefined` immediately —
67
+ * `webhook`/`email`/`sms`/`push-notification`/`analytics`/`monitoring`/
68
+ * `collaboration`/`ai-*` are all real, plausible "also network" categories
69
+ * (named in this file's own §4 candidate list) but widening the filter is
70
+ * separate, deliberate, deferred scope, not silently included or excluded
71
+ * here.
72
+ *
73
+ * Decision table (checked in order):
74
+ * 1. A literal `http://` destination -> `{verdict: 'unprotected', evidenceGrade: 'code'}`
75
+ * unconditionally — the scheme itself is the evidence, no correlation
76
+ * needed.
77
+ * 2. A `crypto-tls-no-verify`/`crypto-tls-version` finding in the site's
78
+ * own file within `TRANSIT_PROTECTION_WINDOW_LINES` lines of the site's
79
+ * own line -> `{verdict: 'unprotected', evidenceGrade: 'code'}` — this
80
+ * OVERRIDES a literal `https://` scheme: a plain scheme is never
81
+ * sufficient evidence of protection once a nearby finding says
82
+ * verification was disabled (AC-04's own core property).
83
+ * 3. A literal `https://` destination with no such nearby finding ->
84
+ * `{verdict: 'protected', evidenceGrade: 'code'}`.
85
+ * 4. Anything else (a dynamic/unresolved destination, or a resolved
86
+ * destination this function has no scheme opinion about) -> `undefined`
87
+ * — the HONEST answer: `emptyProtection()`'s own default
88
+ * (`not_assessed`/`none`) already means exactly that, so this function
89
+ * correctly declines to overwrite it rather than manufacturing a
90
+ * fabricated verdict that implies more analysis happened than
91
+ * actually did.
92
+ *
93
+ * Never throws on a malformed `site`/`transitEvidenceByFile` — mirrors this
94
+ * package's own established defensiveness (`detectUnresolvedDestination`,
95
+ * `resolveDestination`).
96
+ */
97
+ export function resolveTransitProtectionForSite(site, transitEvidenceByFile) {
98
+ if (!site || site.decision?.category !== 'external-api') return undefined;
99
+
100
+ const dest = site.destination;
101
+ const raw = dest?.resolutionStatus === 'literal' ? dest.literalValue : null;
102
+
103
+ if (typeof raw === 'string' && raw.startsWith('http://')) {
104
+ return { verdict: 'unprotected', evidenceGrade: 'code' };
105
+ }
106
+
107
+ const findings = transitEvidenceByFile?.get(site.file) ?? [];
108
+ const nearby = findings.some((f) =>
109
+ TRANSIT_FINDING_FAMILIES.has(f.family)
110
+ && typeof site.line === 'number' && typeof f.line === 'number'
111
+ && Math.abs(f.line - site.line) <= TRANSIT_PROTECTION_WINDOW_LINES);
112
+ if (nearby) return { verdict: 'unprotected', evidenceGrade: 'code' };
113
+
114
+ if (typeof raw === 'string' && raw.startsWith('https://')) {
115
+ return { verdict: 'protected', evidenceGrade: 'code' };
116
+ }
117
+
118
+ return undefined;
119
+ }
120
+
121
+ /**
122
+ * Runs `scanCryptoProtocol` over every file in `fileContents`, collecting
123
+ * any non-empty result into a `Map<file, findings[]>`. Never throws — a
124
+ * per-file detector failure is swallowed (treated as "no findings for that
125
+ * file") rather than aborting the whole scan, matching this package's own
126
+ * "best-effort, never an uncaught throw" convention for optional analysis
127
+ * (see `index.js`'s `buildLineageGraph`).
128
+ *
129
+ * @param {Record<string,string>} [fileContents] `{path: rawSourceString}`,
130
+ * the same shape `runFullScan` already threads to every other whole-file
131
+ * scanner. A non-string value at a given key is skipped, not coerced.
132
+ * @returns {Map<string, object[]>} file -> non-empty findings array. A file
133
+ * with zero findings (including one `scanCryptoProtocol` itself judged
134
+ * not crypto-relevant, or skipped for size) has NO entry — never an
135
+ * entry with an empty array.
136
+ */
137
+ export function scanTransitEvidence(fileContents) {
138
+ const byFile = new Map();
139
+ for (const [file, raw] of Object.entries(fileContents ?? {})) {
140
+ if (typeof raw !== 'string') continue;
141
+ let findings;
142
+ try {
143
+ findings = scanCryptoProtocol(file, raw);
144
+ } catch {
145
+ findings = [];
146
+ }
147
+ if (findings.length) byFile.set(file, findings);
148
+ }
149
+ return byFile;
150
+ }