@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|