@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,394 @@
1
+ // recipient-registry.js — Milestone 4 sub-project: FR-506 ("Third-Party
2
+ // and Cross-Border Intelligence"), Task 2. Resolves a real
3
+ // `RecipientProfile` (Task 1's own extension-contract module,
4
+ // `recipient-profile.js`) from the only two real sources this codebase has
5
+ // for these facts:
6
+ //
7
+ // 1. A small, curated technical-provider catalog, CODE-derived — "what
8
+ // does the code actually call, and what hostname does the resolved
9
+ // destination literally name" (`resolveTechnicalProvider`).
10
+ // 2. An operator-declared config file — facts no code path can ever
11
+ // determine on its own (legal entity, jurisdiction, DPA status, ...)
12
+ // (`loadRecipientConfig`).
13
+ //
14
+ // `buildRecipientProfile` combines the two into one record, per-field
15
+ // `fieldEvidence` intact, and returns `null` when neither source produced
16
+ // anything for a given sink site — a site with genuinely zero recipient
17
+ // information is not worth minting an all-null record for.
18
+ //
19
+ // ── The curated catalog — precision over recall (disclosed, not guessed) ──
20
+ //
21
+ // Mirrors `transform-catalog.js`'s own established discipline: a short,
22
+ // correct list beats a padded, guessed one. Seeded from real signals only:
23
+ //
24
+ // - `anthropic` / `openai` — the ONLY two `framework` values in
25
+ // `dataflow/catalog.js` naming a real AI-provider technical service
26
+ // (confirmed by reading that file directly: `js-anthropic-messages-
27
+ // create`, `js-openai-chat-completions-create`,
28
+ // `js-openai-responses-create`). Matched via BOTH `frameworkNames`
29
+ // (an exact match against the sink site's own `framework`) and real,
30
+ // well-known hostnames for each company (not catalog-derived — no
31
+ // literal hostname string exists anywhere in `catalog.js`/
32
+ // `privacy-catalog.js`, confirmed by grep; these are ordinary,
33
+ // well-known, real-world hostnames for real companies).
34
+ // - Google Cloud Pub/Sub — `privacy-catalog.js`'s `privacy-js-queue-
35
+ // publish` entry carries `framework: 'pubsub'`, and that value is used
36
+ // by EXACTLY ONE catalog entry (confirmed by grep) — unambiguous, so
37
+ // it is matched via `frameworkNames` too.
38
+ // - Amazon S3 — `privacy-catalog.js`'s `privacy-js-s3-putObject` entry
39
+ // carries `category: 's3Upload'` (which `sink-registry.js`'s own
40
+ // `PRIVACY_CATEGORY_MAP` reclassifies to `object-storage`), a real,
41
+ // already-matched signal for a real external recipient. Matched via
42
+ // `hostnamePatterns` ONLY, deliberately NOT via `frameworkNames`:
43
+ // that entry's `framework` value is `'aws-sdk'`, and the IDENTICAL
44
+ // `'aws-sdk'` value is ALSO used by `privacy-js-queue-sendMessage`
45
+ // (Amazon SQS) — genuinely ambiguous, since one `framework` string
46
+ // names two different AWS services with two different `serviceType`s.
47
+ // A `frameworkNames: ['aws-sdk']` catalog entry would silently
48
+ // mislabel whichever of {S3, SQS} it didn't intend, so this module
49
+ // never keys on it at all. No SQS catalog entry is added either — the
50
+ // brief names only S3 as worth considering, and adding a second AWS
51
+ // entry off the same disclosed ambiguity would be scope creep beyond
52
+ // what was actually verified.
53
+ //
54
+ // Disclosed gaps, not guessed at: `mongodb` (storage) and `nodemailer`
55
+ // (email) are genuinely ambiguous — either could be self-hosted or a
56
+ // third-party managed service, and neither entry's own `framework` value
57
+ // reliably names ONE external company the way `anthropic`/`openai`/
58
+ // `pubsub` do — so both are left out. Google Cloud Storage
59
+ // (`storage.googleapis.com`) is also left out: no catalog entry anywhere
60
+ // in this codebase names it (confirmed by grep), so a hostname pattern for
61
+ // it would be an invented signal, not a real one. Stripe/Sentry/Datadog/
62
+ // etc. appear only inside `privacy-js-analytics-track`/`-identify`'s
63
+ // `receiverTypeIn` regex alternation, all sharing ONE `framework` value
64
+ // (`'analytics'`) that cannot distinguish which specific company matched
65
+ // at a given call site — too coarse to use.
66
+ //
67
+ // ── Reuse boundary ──────────────────────────────────────────────────────
68
+ //
69
+ // Imports ONLY `node:fs`, `./recipient-profile.js` (the enums this module
70
+ // validates operator config entries against), `./ids.js`
71
+ // (`recipientProfileId`), and `./export-json.js` (`computeGraphDigest`,
72
+ // the SAME `graph?.graphId ?? null` / `computeGraphDigest(graph)` pattern
73
+ // `obligation-predicates.js`'s `buildObligationMappingFromGraphPredicate`
74
+ // already establishes for a §10.10 extension record). Never
75
+ // `dataflow/engine.js`, never `dataflow/summaries.js` — this module reads
76
+ // only the already-resolved `site`/`graph` shapes other modules hand it.
77
+
78
+ import * as fs from 'node:fs';
79
+ import {
80
+ RECIPIENT_PROCESSOR_ROLES,
81
+ RECIPIENT_DPA_STATUSES,
82
+ RECIPIENT_CONFIDENCE_LEVELS,
83
+ } from './recipient-profile.js';
84
+ import { recipientProfileId } from './ids.js';
85
+ import { computeGraphDigest } from './export-json.js';
86
+
87
+ export const RECIPIENT_CONFIG_FILENAME = 'recipient-profiles.json';
88
+
89
+ // =========================================================================
90
+ // The curated technical-provider catalog.
91
+ // =========================================================================
92
+
93
+ // Each pattern is tested against the BARE HOSTNAME extracted from
94
+ // `literalValue` via `_bareHostname` (fix-round-1, I4 — testing the RAW
95
+ // literal string let a path/query-embedded lookalike like
96
+ // 'https://attacker.io/anthropic.com' incorrectly match) via
97
+ // `new RegExp(p, 'i').test(...)`, so every pattern anchors on a domain
98
+ // BOUNDARY (`(^|[./])`, start-of-string or a literal dot) before the
99
+ // hostname and a domain terminator (`(?=[:/]|$)`, a port/path separator
100
+ // or end-of-string) after it — never a bare `$'` end anchor, which would
101
+ // only ever match a bareHost string containing nothing but the hostname
102
+ // (still correct against a bare host, since that's exactly what it is
103
+ // now).
104
+ export const TECHNICAL_PROVIDER_CATALOG = Object.freeze([
105
+ Object.freeze({
106
+ provider: 'anthropic',
107
+ serviceType: 'ai-model-provider',
108
+ hostnamePatterns: ['(^|[./])anthropic\\.com(?=[:/]|$)'],
109
+ frameworkNames: ['anthropic'],
110
+ }),
111
+ Object.freeze({
112
+ provider: 'openai',
113
+ serviceType: 'ai-model-provider',
114
+ hostnamePatterns: ['(^|[./])openai\\.com(?=[:/]|$)'],
115
+ frameworkNames: ['openai'],
116
+ }),
117
+ Object.freeze({
118
+ provider: 'Google Cloud Pub/Sub',
119
+ serviceType: 'message-queue',
120
+ hostnamePatterns: ['(^|[./])pubsub\\.googleapis\\.com(?=[:/]|$)'],
121
+ frameworkNames: ['pubsub'],
122
+ }),
123
+ Object.freeze({
124
+ provider: 'Amazon S3',
125
+ serviceType: 'object-storage',
126
+ // Hostname-only, deliberately — see the header comment's explanation
127
+ // of why 'aws-sdk' is never used as a frameworkNames match. Covers
128
+ // the plain (`s3.amazonaws.com`), region-qualified
129
+ // (`s3.us-west-2.amazonaws.com` / `s3-us-west-2.amazonaws.com`), and
130
+ // bucket-prefixed virtual-hosted (`my-bucket.s3[...].amazonaws.com`)
131
+ // real URL shapes in one pattern.
132
+ hostnamePatterns: ['(^|[./])s3([.-][a-z0-9-]+)?\\.amazonaws\\.com(?=[:/]|$)'],
133
+ frameworkNames: [],
134
+ }),
135
+ ]);
136
+
137
+ /**
138
+ * `resolveTechnicalProvider({framework, literalValue}) ->
139
+ * {provider, serviceType} | null` — never throws. Matches EITHER a
140
+ * catalog entry's own `frameworkNames` (exact match against the sink
141
+ * site's own `framework`, checked first) OR a `hostnamePatterns` regex
142
+ * test against `literalValue` (when a framework match didn't already
143
+ * resolve it). Returns `null` on no match — never a guess.
144
+ */
145
+ export function resolveTechnicalProvider(input) {
146
+ try {
147
+ const framework = input && typeof input === 'object' ? input.framework : undefined;
148
+ const literalValue = input && typeof input === 'object' ? input.literalValue : undefined;
149
+
150
+ if (typeof framework === 'string' && framework.length > 0) {
151
+ const byFramework = TECHNICAL_PROVIDER_CATALOG.find((e) => e.frameworkNames.includes(framework));
152
+ if (byFramework) return { provider: byFramework.provider, serviceType: byFramework.serviceType };
153
+ }
154
+
155
+ if (typeof literalValue === 'string' && literalValue.length > 0) {
156
+ // fix-round-1, I4: match against the BARE HOSTNAME, never the raw
157
+ // literal — testing the whole string let
158
+ // 'https://attacker.io/anthropic.com' (a path, not a host) and
159
+ // 'https://attacker.io/proxy?to=https://api.anthropic.com' (a query
160
+ // value) both incorrectly match the Anthropic pattern, attaching a
161
+ // real company's operator-declared legal/DPA facts to a flow whose
162
+ // actual destination is attacker.io. `_bareHostname` is defined
163
+ // below in this same file and hoisted, so it's callable from here.
164
+ const bareHost = _bareHostname(literalValue) ?? '';
165
+ const byHost = TECHNICAL_PROVIDER_CATALOG.find((e) =>
166
+ e.hostnamePatterns.some((p) => new RegExp(p, 'i').test(bareHost)));
167
+ if (byHost) return { provider: byHost.provider, serviceType: byHost.serviceType };
168
+ }
169
+
170
+ return null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // =========================================================================
177
+ // A tiny, local bare-hostname extractor — no URL-parsing dependency, per
178
+ // this codebase's established zero-new-dependencies precedent. Handles a
179
+ // scheme-prefixed URL, a bare host, and strips a trailing path/query/
180
+ // fragment/port. Not a general URL parser — good enough for the one thing
181
+ // this module needs it for (a `recipientKey` fallback).
182
+ // =========================================================================
183
+
184
+ function _bareHostname(literalValue) {
185
+ if (typeof literalValue !== 'string' || literalValue.length === 0) return null;
186
+ const withoutScheme = literalValue.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '');
187
+ const host = withoutScheme.split(/[/?#]/)[0].split(':')[0].trim();
188
+ return host.length > 0 ? host.toLowerCase() : null;
189
+ }
190
+
191
+ // =========================================================================
192
+ // loadRecipientConfig — mirrors drift-policy.js's loadDriftPolicies
193
+ // EXACTLY: never throws; a missing file degrades to the empty shape; a
194
+ // malformed-JSON file logs a warning and degrades to the empty shape; each
195
+ // individual recipient entry is validated loosely, and a malformed one is
196
+ // SKIPPED IN ITS ENTIRETY (never just one bad field) with a warning naming
197
+ // the count — mirroring loadDriftPolicies' own per-entry (not per-field)
198
+ // skip discipline. `recipients` is an OBJECT keyed by recipientKey (not an
199
+ // array like `policies`), so this iterates `Object.entries` rather than
200
+ // `.filter()`s an array — the fail-closed philosophy is identical.
201
+ // =========================================================================
202
+
203
+ function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
204
+ function _isStringOrNull(v) { return v === null || v === undefined || typeof v === 'string'; }
205
+ function _isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); }
206
+ function _isCountryArray(v) { return Array.isArray(v) && v.every((c) => typeof c === 'string' && /^[A-Z]{2}$/.test(c)); }
207
+
208
+ // The complete set of operator-declarable fields, per the task brief's own
209
+ // config shape — a 1:1 mapping onto RecipientProfile fields this module
210
+ // can ONLY ever fill from operator config (the fact fields) plus the five
211
+ // record-level metadata fields (confidence/owner/reviewDate/conflicts/
212
+ // expiration), which need no fieldEvidence of their own (see
213
+ // recipient-profile.js's own header comment on why those five are
214
+ // excluded from RECIPIENT_FACT_FIELDS).
215
+ // fix-round-1, M7: `servicePurpose`/`observedRegion` were both real
216
+ // RECIPIENT_FACT_FIELDS (recipient-profile.js) and both real fields
217
+ // `buildRecipientProfile`'s own `facts` object initializes below, but were
218
+ // missing from this list — an operator declaring either in
219
+ // recipient-profiles.json had it silently ignored (never validated, never
220
+ // copied into the built profile).
221
+ const _RECIPIENT_CONFIG_FACT_FIELDS = Object.freeze([
222
+ 'legalEntity', 'processorRole', 'servicePurpose', 'subprocessorChain', 'processingCountries',
223
+ 'dataResidencyCommitment', 'observedRegion', 'dpaStatus', 'transferMechanism',
224
+ 'transferImpactReviewStatus', 'retentionCommitment',
225
+ ]);
226
+ // The remaining five operator-declarable fields — confidence/owner/
227
+ // reviewDate/conflicts/expiration — are record-level metadata, copied
228
+ // directly in buildRecipientProfile's own return literal below with no
229
+ // fieldEvidence entry, per recipient-profile.js's own header comment.
230
+
231
+ /**
232
+ * Validates one recipient-config entry against Task 1's own real enums.
233
+ * Fail-closed per the brief's own instruction: a malformed value on ANY
234
+ * field skips the WHOLE entry (never just that one field) — the simpler,
235
+ * more conservative of the two options the brief names, and the one that
236
+ * mirrors `loadDriftPolicies`' own "validate the whole shape, skip the
237
+ * whole entry on a defect" discipline most closely.
238
+ */
239
+ export function isValidRecipientConfigEntry(entry) {
240
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
241
+ if (!_isStringOrNull(entry.legalEntity)) return false;
242
+ if (entry.processorRole != null && !RECIPIENT_PROCESSOR_ROLES.includes(entry.processorRole)) return false;
243
+ if (!_isStringOrNull(entry.servicePurpose)) return false;
244
+ if (entry.subprocessorChain != null && !_isStringArray(entry.subprocessorChain)) return false;
245
+ if (entry.processingCountries != null && !_isCountryArray(entry.processingCountries)) return false;
246
+ if (!_isStringOrNull(entry.dataResidencyCommitment)) return false;
247
+ if (!_isStringOrNull(entry.observedRegion)) return false;
248
+ if (entry.dpaStatus != null && !RECIPIENT_DPA_STATUSES.includes(entry.dpaStatus)) return false;
249
+ if (!_isStringOrNull(entry.transferMechanism)) return false;
250
+ if (!_isStringOrNull(entry.transferImpactReviewStatus)) return false;
251
+ if (!_isStringOrNull(entry.retentionCommitment)) return false;
252
+ if (entry.confidence != null && !RECIPIENT_CONFIDENCE_LEVELS.includes(entry.confidence)) return false;
253
+ if (!_isStringOrNull(entry.owner)) return false;
254
+ if (!_isStringOrNull(entry.reviewDate)) return false;
255
+ if (entry.conflicts != null && !_isStringArray(entry.conflicts)) return false;
256
+ if (!_isStringOrNull(entry.expiration)) return false;
257
+ return true;
258
+ }
259
+
260
+ export function loadRecipientConfig(configFilePath) {
261
+ const EMPTY = { recipients: {} };
262
+ if (!configFilePath) return EMPTY;
263
+
264
+ let raw = null;
265
+ try {
266
+ raw = JSON.parse(fs.readFileSync(configFilePath, 'utf8'));
267
+ } catch (e) {
268
+ if (e.code !== 'ENOENT') {
269
+ console.error(`agentic-security: bad JSON in recipient config file (${configFilePath}) — falling back to no recipients (${e.message})`);
270
+ }
271
+ return EMPTY;
272
+ }
273
+
274
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !raw.recipients || typeof raw.recipients !== 'object' || Array.isArray(raw.recipients)) {
275
+ console.error(`agentic-security: recipient config file ${configFilePath} has no "recipients" object — falling back to no recipients (expected {"recipients": {...}})`);
276
+ return EMPTY;
277
+ }
278
+
279
+ const recipients = {};
280
+ let skipped = 0;
281
+ for (const [key, entry] of Object.entries(raw.recipients)) {
282
+ if (!_isNonEmptyString(key) || !isValidRecipientConfigEntry(entry)) {
283
+ skipped += 1;
284
+ continue;
285
+ }
286
+ recipients[key] = entry;
287
+ }
288
+ if (skipped > 0) {
289
+ console.error(`agentic-security: skipped ${skipped} malformed recipient-config entr${skipped === 1 ? 'y' : 'ies'} in ${configFilePath} (each must be a valid recipient-profile-shaped object)`);
290
+ }
291
+ return { recipients };
292
+ }
293
+
294
+ // =========================================================================
295
+ // buildRecipientProfile — the real resolution function.
296
+ // =========================================================================
297
+
298
+ /**
299
+ * `buildRecipientProfile(site, graph, opts) -> RecipientProfile | null`.
300
+ *
301
+ * `site` is the shape `graph-builder.js`'s `enumerateSinkSites` produces:
302
+ * `{file, qid, nodeId, line, calleeExpr, args, entry, decision, ambiguity,
303
+ * destination?}` — `entry` is the RAW catalog entry (where `framework`
304
+ * actually lives, `site.entry.framework`, never `site.framework`);
305
+ * `destination`, when present, is `resolve-destination.js`'s own
306
+ * `{resolutionStatus, raw, literalValue, blockingExpression}` shape and
307
+ * may be `undefined` if resolution was never attempted.
308
+ *
309
+ * `graph` is a built `DataFlowGraph v1` document (or `graphAfter` in a
310
+ * diff context) — used only for `graphId`/`graphDigest`, mirroring
311
+ * `obligation-predicates.js`'s own `graph?.graphId ?? null` /
312
+ * `graph ? computeGraphDigest(graph) : null` pattern exactly. `graph` may
313
+ * be omitted/null; the record still builds, honestly labeled `'(no
314
+ * graph)'` (that same precedent's own fallback literal).
315
+ *
316
+ * `opts.recipientConfig` is `loadRecipientConfig`'s own return shape
317
+ * (`{recipients: {...}}`) — omitted/malformed degrades to "no operator
318
+ * declarations", never a throw.
319
+ */
320
+ export function buildRecipientProfile(site, graph, opts = {}) {
321
+ if (!site || typeof site !== 'object') return null;
322
+
323
+ const framework = site.entry && typeof site.entry === 'object' ? site.entry.framework : undefined;
324
+ const destination = site.destination && typeof site.destination === 'object' ? site.destination : null;
325
+ const literalValue = destination && destination.resolutionStatus === 'literal' && typeof destination.literalValue === 'string'
326
+ ? destination.literalValue
327
+ : null;
328
+
329
+ const techMatch = resolveTechnicalProvider({ framework, literalValue });
330
+
331
+ let recipientKey = null;
332
+ if (techMatch && typeof techMatch.provider === 'string' && techMatch.provider.length > 0) {
333
+ recipientKey = techMatch.provider;
334
+ } else {
335
+ recipientKey = _bareHostname(literalValue);
336
+ }
337
+ if (!recipientKey) return null; // no key, no profile — never fabricate one
338
+
339
+ const recipients = opts && opts.recipientConfig && typeof opts.recipientConfig === 'object' && opts.recipientConfig.recipients
340
+ && typeof opts.recipientConfig.recipients === 'object'
341
+ ? opts.recipientConfig.recipients
342
+ : null;
343
+ const configEntry = recipients ? recipients[recipientKey] : null;
344
+
345
+ if (!techMatch && !configEntry) return null; // genuinely nothing to say about this site
346
+
347
+ const fieldEvidence = {};
348
+ const facts = {
349
+ technicalEndpoint: null, provider: null, serviceType: null, legalEntity: null,
350
+ processorRole: null, servicePurpose: null, subprocessorChain: [],
351
+ processingCountries: [], dataResidencyCommitment: null, observedRegion: null,
352
+ dpaStatus: null, transferMechanism: null, transferImpactReviewStatus: null,
353
+ retentionCommitment: null,
354
+ };
355
+
356
+ if (techMatch) {
357
+ facts.provider = techMatch.provider;
358
+ facts.serviceType = techMatch.serviceType;
359
+ fieldEvidence.provider = { factType: 'code_inferred', source: 'recipient-registry:catalog' };
360
+ fieldEvidence.serviceType = { factType: 'code_inferred', source: 'recipient-registry:catalog' };
361
+ if (literalValue) {
362
+ facts.technicalEndpoint = literalValue;
363
+ fieldEvidence.technicalEndpoint = { factType: 'code_inferred', source: 'recipient-registry:catalog' };
364
+ }
365
+ }
366
+
367
+ if (configEntry) {
368
+ for (const field of _RECIPIENT_CONFIG_FACT_FIELDS) {
369
+ const value = configEntry[field];
370
+ const populated = Array.isArray(value) ? value.length > 0 : (value !== null && value !== undefined);
371
+ if (!populated) continue;
372
+ facts[field] = value;
373
+ fieldEvidence[field] = { factType: 'declared', source: RECIPIENT_CONFIG_FILENAME };
374
+ }
375
+ }
376
+
377
+ const graphId = graph && typeof graph === 'object' ? (graph.graphId ?? null) : null;
378
+ const graphDigest = graph && typeof graph === 'object' ? computeGraphDigest(graph) : null;
379
+
380
+ return {
381
+ id: recipientProfileId({ graphId: graphId ?? '', graphDigest: graphDigest ?? '', recipientKey }),
382
+ graphId: graphId ?? '(no graph)',
383
+ graphDigest: graphDigest ?? '(no graph)',
384
+ recipientKey,
385
+ ...facts,
386
+ fieldEvidence,
387
+ contributingGraphIds: [],
388
+ confidence: configEntry && configEntry.confidence != null ? configEntry.confidence : null,
389
+ owner: configEntry && configEntry.owner != null ? configEntry.owner : null,
390
+ reviewDate: configEntry && configEntry.reviewDate != null ? configEntry.reviewDate : null,
391
+ conflicts: configEntry && Array.isArray(configEntry.conflicts) ? configEntry.conflicts : [],
392
+ expiration: configEntry && configEntry.expiration != null ? configEntry.expiration : null,
393
+ };
394
+ }
@@ -0,0 +1,224 @@
1
+ // redact-graph.js — Milestone 4. Shared redaction logic for the
2
+ // DataFlowGraph v1 artifact, extracted from the MCP-tools sub-project's
3
+ // own dataflow-tools.js (where a whole-branch security review found and
4
+ // fixed the real gap this logic closes — see that sub-project's own
5
+ // CLAUDE.md section, "Dataflow-tools redaction scope"). Any export path
6
+ // that returns graph content to a consumer outside the scan process must
7
+ // reuse this module rather than re-deriving redaction logic — that is
8
+ // exactly the "two near-identical copies" bug class this extraction
9
+ // exists to prevent (the same bug class M3-UX-Filters' own
10
+ // `rowMatchesFilters` duplication hit once already this session).
11
+
12
+ import { redactString } from '../mcp/redact.js';
13
+
14
+ // Redacts every scanned-source-derived string field on a COPY of the data
15
+ // being returned — never mutates the loaded graph object, which may be
16
+ // reused across calls within the same process lifetime by other callers.
17
+ //
18
+ // Real source-derived surfaces, confirmed by reading the graph pipeline
19
+ // directly (the destination/evidence findings are from the MCP-tools
20
+ // sub-project's own follow-up security review; the blockingExpression,
21
+ // queueDetail/storeDetail, and coverageReason findings are from the
22
+ // JSON/CSV export sub-project's own final whole-branch review AND its own
23
+ // scoped re-review — the re-review found `coverageReason` carrying the
24
+ // SAME unredacted string as an already-redacted `blockingExpression` on
25
+ // the same object, the identical bug class surviving one review round
26
+ // after the first fix, on a different field — do not repeat that
27
+ // mistake a third time; re-verify against the real pipeline before
28
+ // trusting this list again in the future):
29
+ // - `node.destination.raw`/`.literalValue`/`.blockingExpression` —
30
+ // resolve-destination.js's `resolveDestination()` lifts these straight
31
+ // out of scanned call-site arguments (`renderExpr(arg0)` /
32
+ // `String(arg0.value)`); a hardcoded connection string, webhook URL,
33
+ // or API-key literal used as a destination argument lands here
34
+ // verbatim. `blockingExpression` is NOT a derived/summary field — for
35
+ // a `'dynamic'` resolution, `resolve-destination.js` sets it to the
36
+ // IDENTICAL string as `raw` (confirmed by direct read), so omitting it
37
+ // was a real, exploitable redaction bypass (the whole-branch review's
38
+ // own repro: `raw` redacted, `blockingExpression` carrying the same
39
+ // un-redacted secret verbatim on the very same object).
40
+ // - `node.queueDetail.topic` — `graph-builder.js`'s `extractQueueDetail`
41
+ // lifts this verbatim from a scanned call-site's own object-literal
42
+ // argument (`QueueUrl`/`TopicArn`/`topic`/`queueName`), the identical
43
+ // "literal argument value" shape `destination.literalValue` already
44
+ // covers, just on a different node field.
45
+ // - `node.storeDetail.table`/`.columns` — `graph-builder.js`'s ORM-write
46
+ // extraction lifts these from a scanned receiver identifier and
47
+ // object-literal property keys. Lower risk than the fields above (an
48
+ // identifier/property name, not typically a secret-shaped literal),
49
+ // but still genuinely scanned-source text — redacted defensively for
50
+ // the same reason `evidence[].snippet` is, even though no known
51
+ // producer currently emits a secret-shaped table/column name.
52
+ // - `node.coverageReason` — `sink-registry.js`'s FR-203 branch builds
53
+ // this as `` `destination could not be statically resolved: ${blockingExpression}` ``
54
+ // (confirmed at sink-registry.js's own FR-203 reason-string site,
55
+ // threaded through coverage.js/graph-builder.js to `node.coverageReason`
56
+ // at mint time) — the SAME secret content `destination.blockingExpression`
57
+ // already carries, copied verbatim into a second, unrelated-looking
58
+ // field on the same node. A caller who trusts `destination` alone is
59
+ // redacted correctly still gets the identical secret back via this
60
+ // field. Proven live by the scoped re-review that found this gap.
61
+ // - `graph.recipientProfiles[].technicalEndpoint`/`.legalEntity`/
62
+ // `.retentionCommitment`/`.transferMechanism` — fix-round-1 finding B1
63
+ // (Milestone 4, FR-506): `technicalEndpoint` is lifted verbatim from a
64
+ // resolved destination literal by `recipient-registry.js`'s
65
+ // `buildRecipientProfile`, the SAME "literal URL/hostname lifted from
66
+ // scanned code" shape `node.destination.literalValue` already
67
+ // redacts, so it reached `dataflow_get_graph` unredacted for a whole
68
+ // review round before this was caught. The other three fields are
69
+ // operator-declared free text, covered defensively.
70
+ // - `evidence[].claim` — composed from resolved values in
71
+ // graph-builder.js; can echo the same literal content.
72
+ // - `evidence[].location.note` / `.snippet` — schema-declared free-text
73
+ // fields; `.note` is fixture-only today (the real emitter uses
74
+ // `{file,line}`, never `{note}`) and `.snippet` is always null today,
75
+ // but both are declared string fields a future evidence producer could
76
+ // populate with raw source text, so both stay defensively redacted
77
+ // rather than trusting today's producers to never change.
78
+ function _redactDestination(d) {
79
+ if (!d) return d;
80
+ const hasRedactable = typeof d.raw === 'string' || typeof d.literalValue === 'string' || typeof d.blockingExpression === 'string';
81
+ if (!hasRedactable) return d;
82
+ return {
83
+ ...d,
84
+ raw: typeof d.raw === 'string' ? redactString(d.raw) : d.raw,
85
+ literalValue: typeof d.literalValue === 'string' ? redactString(d.literalValue) : d.literalValue,
86
+ blockingExpression: typeof d.blockingExpression === 'string' ? redactString(d.blockingExpression) : d.blockingExpression,
87
+ };
88
+ }
89
+
90
+ function _redactQueueDetail(q) {
91
+ if (!q || typeof q.topic !== 'string') return q;
92
+ return { ...q, topic: redactString(q.topic) };
93
+ }
94
+
95
+ function _redactStoreDetail(s) {
96
+ if (!s) return s;
97
+ return {
98
+ ...s,
99
+ table: typeof s.table === 'string' ? redactString(s.table) : s.table,
100
+ columns: Array.isArray(s.columns) ? s.columns.map((c) => (typeof c === 'string' ? redactString(c) : c)) : s.columns,
101
+ };
102
+ }
103
+
104
+ export function _redactNode(node) {
105
+ const hasRedactable = node?.destination || node?.queueDetail || node?.storeDetail || typeof node?.coverageReason === 'string';
106
+ if (!hasRedactable) return node;
107
+ const out = { ...node };
108
+ // Only overwrite a key that was actually present on the input — never
109
+ // inject a `key: undefined` own-property onto a node that never had it
110
+ // (found by the scoped re-review: unconditionally writing `destination`/
111
+ // `queueDetail`/`storeDetail` added an own `undefined` value even when
112
+ // absent from the source node, which JSON.stringify silently drops but
113
+ // which could still trip a `hasOwnProperty` structural check elsewhere,
114
+ // e.g. validate.js's own storeDetail.columns guard).
115
+ if ('destination' in node) out.destination = _redactDestination(node.destination);
116
+ if ('queueDetail' in node) out.queueDetail = _redactQueueDetail(node.queueDetail);
117
+ if ('storeDetail' in node) out.storeDetail = _redactStoreDetail(node.storeDetail);
118
+ if (typeof node.coverageReason === 'string') out.coverageReason = redactString(node.coverageReason);
119
+ return out;
120
+ }
121
+
122
+ export function _redactEvidence(evidence) {
123
+ if (!Array.isArray(evidence)) return evidence;
124
+ return evidence.map((e) => {
125
+ if (!e || typeof e !== 'object') return e;
126
+ return {
127
+ ...e,
128
+ claim: typeof e.claim === 'string' ? redactString(e.claim) : e.claim,
129
+ snippet: typeof e.snippet === 'string' ? redactString(e.snippet) : e.snippet,
130
+ location: e.location?.note
131
+ ? { ...e.location, note: redactString(e.location.note) }
132
+ : e.location,
133
+ };
134
+ });
135
+ }
136
+
137
+ // Milestone 4, fix-round 1, B1: `graph.recipientProfiles[]` (FR-506) is a
138
+ // real scanned-source-derived surface `_redactGraph` never touched —
139
+ // `technicalEndpoint` is lifted verbatim from a resolved destination
140
+ // literal by `recipient-registry.js`'s `buildRecipientProfile` (the exact
141
+ // same "literal URL/hostname lifted from scanned code" shape
142
+ // `node.destination.literalValue` already redacts above), so it can
143
+ // carry an embedded credential/token just as easily. `legalEntity`/
144
+ // `retentionCommitment`/`transferMechanism` are operator-declared free
145
+ // text from `recipient-profiles.json`, not scanned-source-derived — but
146
+ // `redactString` is pattern-based and a safe no-op on ordinary text
147
+ // (confirmed by reading `mcp/redact.js`), so covering them defensively
148
+ // costs nothing. Not exported: called only from `_redactGraph` below,
149
+ // the same "internal helper" precedent `_redactDestination`/
150
+ // `_redactQueueDetail`/`_redactStoreDetail` already establish in this
151
+ // file (unlike `_redactNode`/`_redactEvidence`, which ARE exported
152
+ // because callers outside this module invoke them directly).
153
+ function _redactRecipientProfile(p) {
154
+ if (!p) return p;
155
+ const hasRedactable = typeof p.technicalEndpoint === 'string'
156
+ || typeof p.legalEntity === 'string'
157
+ || typeof p.retentionCommitment === 'string'
158
+ || typeof p.transferMechanism === 'string';
159
+ if (!hasRedactable) return p;
160
+ return {
161
+ ...p,
162
+ technicalEndpoint: typeof p.technicalEndpoint === 'string' ? redactString(p.technicalEndpoint) : p.technicalEndpoint,
163
+ legalEntity: typeof p.legalEntity === 'string' ? redactString(p.legalEntity) : p.legalEntity,
164
+ retentionCommitment: typeof p.retentionCommitment === 'string' ? redactString(p.retentionCommitment) : p.retentionCommitment,
165
+ transferMechanism: typeof p.transferMechanism === 'string' ? redactString(p.transferMechanism) : p.transferMechanism,
166
+ };
167
+ }
168
+
169
+ // CrossRepoLink redaction (final whole-branch review, M5 deliverable
170
+ // #8, B2; extended in a second fix round after the round-1 re-review
171
+ // found `remote.sourceFile`/`declaredBy` still leaking). Two DIFFERENT
172
+ // treatments for two DIFFERENT kinds of field:
173
+ // - `rationale`/`remote.repository` are genuine operator-declared free
174
+ // text — `redactString` (pattern-based secret scrubbing) is the
175
+ // correct, established treatment, same precedent
176
+ // `_redactRecipientProfile` already uses for similarly-shaped
177
+ // fields; a safe no-op on ordinary text.
178
+ // - `remote.sourceFile` (a real local filesystem path) and
179
+ // `declaredBy` (a real OS username, from `process.env.USER`/
180
+ // `USERNAME`) are STRUCTURALLY guaranteed to carry machine-layout/
181
+ // identity information regardless of content — no secret PATTERN
182
+ // to match, so `redactString` is a no-op on them by construction,
183
+ // which is exactly the round-1 mistake this round fixes. These two
184
+ // get UNCONDITIONAL full-token replacement whenever present, never
185
+ // partial pattern-based scrubbing — there is no "clean" version of
186
+ // a local path or username worth preserving in an exported artifact
187
+ // (the REAL value is still needed internally by `federate list`
188
+ // re-validation, which reads the stored `cross-repo-links.json`
189
+ // directly, never this redacted export view).
190
+ function _redactCrossRepoLink(link) {
191
+ if (!link) return link;
192
+ const hasRedactable = typeof link.rationale === 'string'
193
+ || typeof link.declaredBy === 'string'
194
+ || (link.remote && typeof link.remote.sourceFile === 'string')
195
+ || (link.remote && typeof link.remote.repository === 'string');
196
+ if (!hasRedactable) return link;
197
+ return {
198
+ ...link,
199
+ rationale: typeof link.rationale === 'string' ? redactString(link.rationale) : link.rationale,
200
+ declaredBy: typeof link.declaredBy === 'string' ? '[REDACTED:username]' : link.declaredBy,
201
+ remote: link.remote ? {
202
+ ...link.remote,
203
+ sourceFile: typeof link.remote.sourceFile === 'string' ? '[REDACTED:local-path]' : link.remote.sourceFile,
204
+ repository: typeof link.remote.repository === 'string' ? redactString(link.remote.repository) : link.remote.repository,
205
+ } : link.remote,
206
+ };
207
+ }
208
+
209
+ // Full-graph redaction: every node's destination, the top-level evidence
210
+ // array, (fix-round 1, B1) recipientProfiles[], and (final review, M5 #8
211
+ // B2) crossRepoLinks[]. Edges/flows carry only evidenceRefs (id strings
212
+ // into graph.evidence), never embedded evidence objects or
213
+ // destination-shaped fields — confirmed against dataflow-graph.schema.json
214
+ // — so they need no redaction pass of their own here.
215
+ export function _redactGraph(data) {
216
+ if (!data) return data;
217
+ return {
218
+ ...data,
219
+ nodes: Array.isArray(data.nodes) ? data.nodes.map(_redactNode) : data.nodes,
220
+ evidence: _redactEvidence(data.evidence),
221
+ recipientProfiles: Array.isArray(data.recipientProfiles) ? data.recipientProfiles.map(_redactRecipientProfile) : data.recipientProfiles,
222
+ crossRepoLinks: Array.isArray(data.crossRepoLinks) ? data.crossRepoLinks.map(_redactCrossRepoLink) : data.crossRepoLinks,
223
+ };
224
+ }