src2sink 1.0.0__py3-none-any.whl

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 (81) hide show
  1. src2sink/__init__.py +1 -0
  2. src2sink/aggregators/__init__.py +3 -0
  3. src2sink/aggregators/api_client_discovery.py +259 -0
  4. src2sink/aggregators/auth_cards.py +116 -0
  5. src2sink/aggregators/crypto_cards.py +132 -0
  6. src2sink/aggregators/data_stores.py +125 -0
  7. src2sink/aggregators/graphs.py +36 -0
  8. src2sink/aggregators/index_v2.py +138 -0
  9. src2sink/aggregators/library_source_map.py +188 -0
  10. src2sink/aggregators/openapi_discovery.py +113 -0
  11. src2sink/aggregators/openapi_edges.py +30 -0
  12. src2sink/aggregators/openapi_match.py +81 -0
  13. src2sink/aggregators/openapi_models.py +25 -0
  14. src2sink/aggregators/openapi_report.py +75 -0
  15. src2sink/aggregators/payload_producers.py +369 -0
  16. src2sink/aggregators/phase3.py +30 -0
  17. src2sink/aggregators/pii_cross_repo.py +283 -0
  18. src2sink/aggregators/pii_flow_v2.py +118 -0
  19. src2sink/aggregators/pii_lifecycle.py +19 -0
  20. src2sink/aggregators/pii_lifecycle_report.py +121 -0
  21. src2sink/aggregators/pii_touchpoint_collect.py +134 -0
  22. src2sink/aggregators/pii_touchpoint_utils.py +96 -0
  23. src2sink/aggregators/queues.py +141 -0
  24. src2sink/aggregators/ropa.py +190 -0
  25. src2sink/aggregators/service_call_collect.py +240 -0
  26. src2sink/aggregators/service_call_index.py +31 -0
  27. src2sink/aggregators/service_call_models.py +17 -0
  28. src2sink/aggregators/service_call_report.py +118 -0
  29. src2sink/aggregators/service_calls.py +14 -0
  30. src2sink/aggregators/taint_buckets.py +74 -0
  31. src2sink/aggregators/taint_catalogs.py +35 -0
  32. src2sink/aggregators/taint_writers.py +369 -0
  33. src2sink/aggregators/traces_index.py +103 -0
  34. src2sink/build_metabase_v2.py +807 -0
  35. src2sink/constants.py +44 -0
  36. src2sink/curate_internal_libraries.py +135 -0
  37. src2sink/extractors/__init__.py +17 -0
  38. src2sink/extractors/ast_walk.py +131 -0
  39. src2sink/extractors/base.py +59 -0
  40. src2sink/extractors/config.py +224 -0
  41. src2sink/extractors/file_context.py +35 -0
  42. src2sink/extractors/go.py +3 -0
  43. src2sink/extractors/http_out.py +108 -0
  44. src2sink/extractors/java.py +3 -0
  45. src2sink/extractors/javascript.py +3 -0
  46. src2sink/extractors/kotlin.py +3 -0
  47. src2sink/extractors/node_factory.py +65 -0
  48. src2sink/extractors/patterns.py +145 -0
  49. src2sink/extractors/python_extractor.py +3 -0
  50. src2sink/extractors/regex_extractors.py +378 -0
  51. src2sink/extractors/ts_extractors.py +123 -0
  52. src2sink/extractors/unified.py +64 -0
  53. src2sink/graph_common.py +213 -0
  54. src2sink/internal_groups.py +142 -0
  55. src2sink/known_api_clients.py +108 -0
  56. src2sink/library_taint_java.py +92 -0
  57. src2sink/limits.py +171 -0
  58. src2sink/models/__init__.py +3 -0
  59. src2sink/models/auth_model.py +3 -0
  60. src2sink/models/crypto_agility.py +3 -0
  61. src2sink/models/pii_lifecycle.py +92 -0
  62. src2sink/models/ropa.py +37 -0
  63. src2sink/prescreen.py +93 -0
  64. src2sink/py.typed +0 -0
  65. src2sink/record_fleet_baseline.py +81 -0
  66. src2sink/renderers/__init__.py +3 -0
  67. src2sink/renderers/markdown.py +149 -0
  68. src2sink/renderers/mermaid.py +3 -0
  69. src2sink/repo_utils.py +885 -0
  70. src2sink/safe_paths.py +54 -0
  71. src2sink/sanitize.py +107 -0
  72. src2sink/schema.py +57 -0
  73. src2sink/trace.py +558 -0
  74. src2sink/trace_batch.py +176 -0
  75. src2sink/vocabulary.py +152 -0
  76. src2sink-1.0.0.dist-info/METADATA +355 -0
  77. src2sink-1.0.0.dist-info/RECORD +81 -0
  78. src2sink-1.0.0.dist-info/WHEEL +5 -0
  79. src2sink-1.0.0.dist-info/entry_points.txt +6 -0
  80. src2sink-1.0.0.dist-info/licenses/LICENSE +21 -0
  81. src2sink-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,369 @@
1
+ """Index repos that produce traffic to dangerous-payload HTTP endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections import defaultdict
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..graph_common import host_matches_repo, load_v2_repo_records, repo_id
13
+ from ..known_api_clients import ApiClientBinding, get_bindings
14
+ from ..renderers.markdown import md_table
15
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE
16
+
17
+ IMPORT_SCAN_RX = re.compile(
18
+ r"^\s*import\s+([\w.]+)|"
19
+ r"import\s+([\w.]+)",
20
+ re.MULTILINE,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class ProducerHit:
26
+ """A single evidence that one repo produces traffic to a payload endpoint."""
27
+
28
+ source_repo: str
29
+ target_repo: str
30
+ path: str
31
+ kind: str
32
+ confidence: str
33
+ evidence: str
34
+ ref: str = ""
35
+
36
+
37
+ @dataclass
38
+ class ProducerIndex:
39
+ """Per-target index: an API-client binding and the producer hits found for it."""
40
+
41
+ binding: ApiClientBinding
42
+ hits: list[ProducerHit] = field(default_factory=list)
43
+
44
+
45
+ def _internal_dep_hit(
46
+ rid: str, binding: ApiClientBinding, data: dict[str, Any]
47
+ ) -> ProducerHit | None:
48
+ """Return a hit if the repo declares an internal dependency on the client artifact."""
49
+ for dep in data.get("dependencies_internal", []):
50
+ aid = dep.get("artifactId", "")
51
+ if binding.maven_artifact in aid:
52
+ return ProducerHit(
53
+ source_repo=rid,
54
+ target_repo=binding.target_repo,
55
+ path="*",
56
+ kind="internal-dep",
57
+ confidence="high",
58
+ evidence=f"{dep.get('groupId')}:{aid}",
59
+ )
60
+ return None
61
+
62
+
63
+ def _api_client_consumer_hit(
64
+ rid: str, binding: ApiClientBinding, detail: dict[str, Any], ref: str
65
+ ) -> ProducerHit | None:
66
+ """Return a hit if an `api-client-consumer` node targets the binding's repo."""
67
+ if detail.get("target_repo") != binding.target_repo:
68
+ return None
69
+ return ProducerHit(
70
+ source_repo=rid,
71
+ target_repo=binding.target_repo,
72
+ path=",".join(detail.get("paths", [])) or "*",
73
+ kind="import",
74
+ confidence="high",
75
+ evidence=detail.get("import", "")[:120],
76
+ ref=ref,
77
+ )
78
+
79
+
80
+ def _http_out_hit(
81
+ rid: str, binding: ApiClientBinding, detail: dict[str, Any], ref: str
82
+ ) -> ProducerHit | None:
83
+ """Return a hit if an `http-out` node's host or path matches the binding's target."""
84
+ host = detail.get("host", "")
85
+ path = detail.get("path", "")
86
+ if host and host_matches_repo(host, binding.target_repo):
87
+ return ProducerHit(
88
+ source_repo=rid,
89
+ target_repo=binding.target_repo,
90
+ path=path or "*",
91
+ kind="http-out",
92
+ confidence="high" if path else "medium",
93
+ evidence=detail.get("url") or detail.get("raw", "")[:120],
94
+ ref=ref,
95
+ )
96
+ if path and any(
97
+ path.rstrip("/").endswith(p.rstrip("/").replace("{handle}", ""))
98
+ or p in (detail.get("paths") or [])
99
+ for p in binding.paths
100
+ ):
101
+ return ProducerHit(
102
+ source_repo=rid,
103
+ target_repo=binding.target_repo,
104
+ path=path,
105
+ kind="http-out-path",
106
+ confidence="medium",
107
+ evidence=detail.get("raw", "")[:120],
108
+ ref=ref,
109
+ )
110
+ return None
111
+
112
+
113
+ def _hits_from_repo_json(data: dict[str, Any], binding: ApiClientBinding) -> list[ProducerHit]:
114
+ """Collect all producer hits for one binding from a single repo's v2 JSON."""
115
+ rid = repo_id(data)
116
+ if rid == binding.target_repo:
117
+ return []
118
+ hits: list[ProducerHit] = []
119
+ dep_hit = _internal_dep_hit(rid, binding, data)
120
+ if dep_hit:
121
+ hits.append(dep_hit)
122
+
123
+ for node in data.get("nodes", []):
124
+ family = node.get("family", "")
125
+ detail = node.get("detail") or {}
126
+ ref = f"{node.get('file')}:{node.get('line')}"
127
+ if family == "api-client-consumer":
128
+ hit = _api_client_consumer_hit(rid, binding, detail, ref)
129
+ elif family == "http-out":
130
+ hit = _http_out_hit(rid, binding, detail, ref)
131
+ else:
132
+ hit = None
133
+ if hit:
134
+ hits.append(hit)
135
+ return hits
136
+
137
+
138
+ _BINDING_SCAN_SUFFIXES = frozenset({".java", ".kt", ".kts", ".xml", ".gradle"})
139
+ _BINDING_SCAN_SKIP = frozenset({"node_modules", ".git", "target", "build"})
140
+ _BUILD_FILE_NAMES = ("pom.xml", "build.gradle", "build.gradle.kts")
141
+
142
+
143
+ def _read_capped(path: Path, cap: int = 400_000) -> str | None:
144
+ """Read a file as text, returning None if it exceeds the byte cap or errors."""
145
+ try:
146
+ if path.stat().st_size > cap:
147
+ return None
148
+ return path.read_text(encoding="utf-8", errors="replace")
149
+ except OSError:
150
+ return None
151
+
152
+
153
+ def _import_hits_in_file(
154
+ text: str, path: Path, repo_dir: Path, src: str,
155
+ binding: ApiClientBinding, seen: set[tuple[str, str]],
156
+ ) -> list[ProducerHit]:
157
+ """Scan one file's imports for the binding's import prefix, deduped via `seen`."""
158
+ hits: list[ProducerHit] = []
159
+ for m in IMPORT_SCAN_RX.finditer(text):
160
+ pkg = m.group(1) or m.group(2) or ""
161
+ if binding.import_prefix in pkg and (src, "import") not in seen:
162
+ seen.add((src, "import"))
163
+ line = text.count("\n", 0, m.start()) + 1
164
+ hits.append(ProducerHit(
165
+ source_repo=src,
166
+ target_repo=binding.target_repo,
167
+ path="*",
168
+ kind="import-scan",
169
+ confidence="high",
170
+ evidence=f"import {pkg}",
171
+ ref=f"{path.relative_to(repo_dir)}:{line}",
172
+ ))
173
+ return hits
174
+
175
+
176
+ def _scan_repo_for_binding(
177
+ repo_dir: Path, src: str, binding: ApiClientBinding,
178
+ seen: set[tuple[str, str]], pom_rx: re.Pattern[str], gradle_rx: re.Pattern[str],
179
+ ) -> list[ProducerHit]:
180
+ """Scan a repo directory tree for imports and build-file references to the binding."""
181
+ hits: list[ProducerHit] = []
182
+ found_pom = False
183
+ for path in repo_dir.rglob("*"):
184
+ if path.suffix.lower() not in _BINDING_SCAN_SUFFIXES:
185
+ continue
186
+ if any(p in path.parts for p in _BINDING_SCAN_SKIP):
187
+ continue
188
+ text = _read_capped(path)
189
+ if text is None:
190
+ continue
191
+ if path.name in _BUILD_FILE_NAMES and (pom_rx.search(text) or gradle_rx.search(text)):
192
+ found_pom = True
193
+ hits.extend(_import_hits_in_file(text, path, repo_dir, src, binding, seen))
194
+
195
+ if found_pom and (src, "pom") not in seen:
196
+ seen.add((src, "pom"))
197
+ hits.append(ProducerHit(
198
+ source_repo=src,
199
+ target_repo=binding.target_repo,
200
+ path="*",
201
+ kind="build-dep-scan",
202
+ confidence="high",
203
+ evidence=binding.maven_artifact,
204
+ ))
205
+ return hits
206
+
207
+
208
+ def _scan_repos_for_binding(
209
+ repos_root: Path,
210
+ binding: ApiClientBinding,
211
+ ) -> list[ProducerHit]:
212
+ """Scan every repo under `repos_root` for source-level uses of the binding."""
213
+ if not repos_root.is_dir():
214
+ return []
215
+ hits: list[ProducerHit] = []
216
+ seen: set[tuple[str, str]] = set()
217
+ pom_rx = re.compile(
218
+ rf"<artifactId>({re.escape(binding.maven_artifact)})</artifactId>",
219
+ re.IGNORECASE,
220
+ )
221
+ gradle_rx = re.compile(re.escape(binding.maven_artifact), re.IGNORECASE)
222
+
223
+ for group_dir in repos_root.iterdir():
224
+ if not group_dir.is_dir():
225
+ continue
226
+ for repo_dir in group_dir.iterdir():
227
+ if not repo_dir.is_dir():
228
+ continue
229
+ src = f"{group_dir.name}/{repo_dir.name}"
230
+ if src != binding.target_repo:
231
+ hits.extend(
232
+ _scan_repo_for_binding(repo_dir, src, binding, seen, pom_rx, gradle_rx)
233
+ )
234
+ return hits
235
+
236
+
237
+ def build_producer_indices(
238
+ metabase_root: Path,
239
+ *,
240
+ repos_root: Path | None = None,
241
+ json_paths: list[Path] | None = None,
242
+ ) -> list[ProducerIndex]:
243
+ """Build a producer index per known API-client binding.
244
+
245
+ Merges v2-JSON hits with optional source-tree scan hits, keeping the
246
+ highest-confidence hit per (repo, kind).
247
+
248
+ Args:
249
+ metabase_root: Metabase root holding per-repo v2 JSON.
250
+ repos_root: Optional checkout root to scan for imports/build deps.
251
+ json_paths: Explicit v2 JSON paths (else discovered under metabase_root).
252
+
253
+ Returns:
254
+ One ProducerIndex per binding, hits sorted by (source_repo, kind).
255
+ """
256
+ records = load_v2_repo_records(metabase_root, json_paths=json_paths)
257
+ indices: list[ProducerIndex] = []
258
+
259
+ for binding in get_bindings():
260
+ by_repo: dict[str, list[ProducerHit]] = defaultdict(list)
261
+
262
+ for data in records:
263
+ for hit in _hits_from_repo_json(data, binding):
264
+ by_repo[hit.source_repo].append(hit)
265
+
266
+ if repos_root:
267
+ for hit in _scan_repos_for_binding(repos_root, binding):
268
+ by_repo[hit.source_repo].append(hit)
269
+
270
+ # Merge per repo: keep highest confidence per kind
271
+ merged: list[ProducerHit] = []
272
+ for src, repo_hits in sorted(by_repo.items()):
273
+ kinds: dict[str, ProducerHit] = {}
274
+ for h in repo_hits:
275
+ prev = kinds.get(h.kind)
276
+ if prev is None or _conf_rank(h.confidence) > _conf_rank(prev.confidence):
277
+ kinds[h.kind] = h
278
+ merged.extend(kinds.values())
279
+
280
+ indices.append(ProducerIndex(binding=binding, hits=sorted(
281
+ merged,
282
+ key=lambda h: (h.source_repo, h.kind),
283
+ )))
284
+
285
+ return indices
286
+
287
+
288
+ def _conf_rank(c: str) -> int:
289
+ """Map a confidence label to a sortable integer rank."""
290
+ return {"high": 3, "medium": 2, "low": 1}.get(c, 0)
291
+
292
+
293
+ def write_payload_producer_index(
294
+ metabase_root: Path,
295
+ repo_jsons: list[Path],
296
+ *,
297
+ repos_root: Path | None = None,
298
+ ) -> None:
299
+ """Write the payload-endpoint-producers markdown + jsonl catalogue."""
300
+ root = repos_root
301
+ if root is None:
302
+ candidate = metabase_root.parent / "repos"
303
+ root = candidate if candidate.is_dir() else None
304
+
305
+ indices = build_producer_indices(
306
+ metabase_root,
307
+ repos_root=root,
308
+ json_paths=repo_jsons,
309
+ )
310
+
311
+ out_dir = metabase_root / "graphs"
312
+ out_dir.mkdir(parents=True, exist_ok=True)
313
+ jsonl_path = out_dir / "payload-endpoint-producers.jsonl"
314
+ all_records: list[dict[str, Any]] = []
315
+
316
+ md: list[str] = [
317
+ "# Payload endpoint producers\n",
318
+ UNTRUSTED_CONTENT_NOTICE,
319
+ "_Repos that call dangerous-payload HTTP APIs via published clients, "
320
+ "declared dependencies, or enriched `http-out` nodes. Built from "
321
+ "`known_api_clients.py` + optional `repos/` import scan._\n",
322
+ ]
323
+
324
+ for index in indices:
325
+ binding = index.binding
326
+ md.append(f"\n## Target: `{binding.target_repo}`\n")
327
+ md.append(
328
+ f"Client: `{binding.maven_artifact}` · paths: "
329
+ + ", ".join(f"`{p}`" for p in binding.paths)
330
+ + "\n",
331
+ )
332
+ if not index.hits:
333
+ md.append("_No producers indexed._\n")
334
+ continue
335
+
336
+ md.append(md_table(
337
+ ["Producer repo", "Kind", "Path", "Confidence", "Evidence", "Ref"],
338
+ [
339
+ [
340
+ h.source_repo,
341
+ h.kind,
342
+ h.path,
343
+ h.confidence,
344
+ h.evidence[:90],
345
+ h.ref,
346
+ ]
347
+ for h in index.hits[:200]
348
+ ],
349
+ ))
350
+ if len(index.hits) > 200:
351
+ md.append(f"\n_{len(index.hits) - 200} more in jsonl._\n")
352
+
353
+ for h in index.hits:
354
+ rec = {
355
+ "target_repo": h.target_repo,
356
+ "source_repo": h.source_repo,
357
+ "path": h.path,
358
+ "kind": h.kind,
359
+ "confidence": h.confidence,
360
+ "evidence": h.evidence,
361
+ "ref": h.ref,
362
+ "client_artifact": binding.maven_artifact,
363
+ }
364
+ all_records.append(rec)
365
+
366
+ (out_dir / "payload-endpoint-producers.md").write_text("\n".join(md), encoding="utf-8")
367
+ with jsonl_path.open("w", encoding="utf-8") as fh:
368
+ for rec in all_records:
369
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
@@ -0,0 +1,30 @@
1
+ """Phase 3 aggregators: PII lifecycle, ROPA, auth/crypto conventions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .auth_cards import write_auth_models_catalog
8
+ from .crypto_cards import write_crypto_agility_catalog
9
+ from .pii_cross_repo import write_pii_cross_repo_graph
10
+ from ..models.ropa import ROPA_SHOWCASE_FIELDS
11
+ from .pii_lifecycle import write_pii_lifecycle_graph
12
+ from .ropa import write_ropa_view
13
+
14
+
15
+ def aggregate_phase3_v2(
16
+ metabase_root: Path,
17
+ repo_jsons: list[Path],
18
+ ) -> None:
19
+ """Run the phase 3 aggregators: PII lifecycle/cross-repo, ROPA, auth, and crypto."""
20
+ touches = write_pii_lifecycle_graph(metabase_root, repo_jsons)
21
+ for field_key in sorted(ROPA_SHOWCASE_FIELDS):
22
+ write_pii_cross_repo_graph(
23
+ metabase_root,
24
+ touches,
25
+ repo_jsons,
26
+ field_key=field_key,
27
+ )
28
+ write_ropa_view(metabase_root, repo_jsons, touches=touches)
29
+ write_auth_models_catalog(metabase_root, repo_jsons)
30
+ write_crypto_agility_catalog(metabase_root, repo_jsons)
@@ -0,0 +1,283 @@
1
+ """Cross-repo PII flow links (queue hops + HTTP edges) for showcase fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .service_calls import collect_service_edges
11
+ from ..graph_common import iter_nodes, load_v2_repo_records, repo_id
12
+ from ..models.pii_lifecycle import PiiTouchpoint
13
+ from ..renderers.markdown import md_table
14
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE, for_markdown
15
+
16
+ MAX_FLOW_ROWS = 80
17
+
18
+
19
+ def _repos_with_field(
20
+ touches: list[PiiTouchpoint],
21
+ field_key: str,
22
+ ) -> set[str]:
23
+ """Return repos with any touchpoint for the given field."""
24
+ return {t.repo for t in touches if t.field_key == field_key}
25
+
26
+
27
+ # Cross-repo hops: repos must have material handling of the field (not merely
28
+ # a pii-field declaration or a low-confidence "nearby http-out/sql" hint).
29
+ _MATERIAL_FAMILIES = frozenset({"pii-field", "pii-log", "pii-storage", "queue-pub"})
30
+ _MATERIAL_STAGES = frozenset({"store", "log", "collect", "delete", "encrypt"})
31
+ _STRONG_FAMILIES = frozenset({"pii-log", "pii-storage", "queue-pub", "queue-sub"})
32
+ # Generic routes shared by many services — not indicative of PII data flow.
33
+ _HTTP_NOISE_PATHS = frozenset({
34
+ "/metrics",
35
+ "/health",
36
+ "/healthz",
37
+ "/ready",
38
+ "/live",
39
+ "/actuator",
40
+ "/swagger",
41
+ "/openapi",
42
+ "/api/health",
43
+ "/status",
44
+ })
45
+
46
+
47
+ def _material_repos_with_field(
48
+ touches: list[PiiTouchpoint],
49
+ field_key: str,
50
+ ) -> set[str]:
51
+ """Return repos that materially handle the field (not mere declarations/hints)."""
52
+ out: set[str] = set()
53
+ for t in touches:
54
+ if t.field_key != field_key:
55
+ continue
56
+ if t.family in _MATERIAL_FAMILIES:
57
+ out.add(t.repo)
58
+ elif t.stage in _MATERIAL_STAGES:
59
+ out.add(t.repo)
60
+ elif t.stage == "transmit" and t.confidence != "low":
61
+ out.add(t.repo)
62
+ return out
63
+
64
+
65
+ def _strong_material_repos_with_field(
66
+ touches: list[PiiTouchpoint],
67
+ field_key: str,
68
+ ) -> set[str]:
69
+ """Repos with log/store/queue touchpoints — used for HTTP cross-repo hops."""
70
+ out: set[str] = set()
71
+ for t in touches:
72
+ if t.field_key != field_key:
73
+ continue
74
+ if t.family in _STRONG_FAMILIES:
75
+ out.add(t.repo)
76
+ elif t.stage in ("store", "log", "delete", "encrypt"):
77
+ out.add(t.repo)
78
+ return out
79
+
80
+
81
+ def _is_noise_http_path(path: str) -> bool:
82
+ """Return True for generic infra paths (health/metrics/etc.) that aren't PII flow."""
83
+ norm = path.split("?")[0].rstrip("/") or "/"
84
+ if norm in _HTTP_NOISE_PATHS:
85
+ return True
86
+ for prefix in _HTTP_NOISE_PATHS:
87
+ if norm.startswith(prefix + "/"):
88
+ return True
89
+ return False
90
+
91
+
92
+ def _queue_topics_by_repo(records: list[dict[str, Any]]) -> dict[str, dict[str, set[str]]]:
93
+ """repo -> {produce: topics, consume: topics}."""
94
+ out: dict[str, dict[str, set[str]]] = defaultdict(
95
+ lambda: {"produce": set(), "consume": set()},
96
+ )
97
+ for data in records:
98
+ rid = repo_id(data)
99
+ for node in iter_nodes(data):
100
+ family = node.get("family", "")
101
+ topic = (node.get("detail") or {}).get("topic", "")
102
+ if not topic or topic == "?":
103
+ continue
104
+ if family == "queue-pub":
105
+ out[rid]["produce"].add(topic)
106
+ elif family == "queue-sub":
107
+ out[rid]["consume"].add(topic)
108
+ return out
109
+
110
+
111
+ def _build_queue_flows(
112
+ records: list[dict[str, Any]],
113
+ *,
114
+ field_key: str,
115
+ material_repos: set[str],
116
+ ) -> list[dict[str, Any]]:
117
+ """Link repos that share a topic and both materially touch the field."""
118
+ flows: list[dict[str, Any]] = []
119
+ queue_map = _queue_topics_by_repo(records)
120
+ topics: dict[str, dict[str, set[str]]] = defaultdict(
121
+ lambda: {"produce": set(), "consume": set()},
122
+ )
123
+ for rid, dirs in queue_map.items():
124
+ if rid not in material_repos:
125
+ continue
126
+ for topic in dirs["produce"]:
127
+ topics[topic]["produce"].add(rid)
128
+ for topic in dirs["consume"]:
129
+ topics[topic]["consume"].add(rid)
130
+
131
+ for topic, dirs in sorted(topics.items()):
132
+ producers = sorted(dirs["produce"] & material_repos)
133
+ consumers = sorted(dirs["consume"] & material_repos)
134
+ if not producers or not consumers:
135
+ continue
136
+ for p in producers:
137
+ for c in consumers:
138
+ if p == c:
139
+ continue
140
+ flows.append({
141
+ "field_key": field_key,
142
+ "kind": "queue",
143
+ "source_repo": p,
144
+ "target_repo": c,
145
+ "via": topic,
146
+ "confidence": "medium",
147
+ "evidence": f"Both repos touch `{field_key}`; topic `{topic}`",
148
+ })
149
+ return flows
150
+
151
+
152
+ def _build_http_flows(
153
+ records: list[dict[str, Any]],
154
+ *,
155
+ field_key: str,
156
+ strong_repos: set[str],
157
+ ) -> list[dict[str, Any]]:
158
+ """Link high/openapi service-call edges where both repos strongly touch the field."""
159
+ flows: list[dict[str, Any]] = []
160
+ edges, _ = collect_service_edges(records)
161
+ for edge in edges:
162
+ if edge.confidence not in ("high", "openapi"):
163
+ continue
164
+ if _is_noise_http_path(edge.target_path):
165
+ continue
166
+ if edge.source_repo not in strong_repos or edge.target_repo not in strong_repos:
167
+ continue
168
+ flows.append({
169
+ "field_key": field_key,
170
+ "kind": "http",
171
+ "source_repo": edge.source_repo,
172
+ "target_repo": edge.target_repo,
173
+ "via": edge.target_path,
174
+ "confidence": edge.confidence,
175
+ "evidence": edge.evidence[:120],
176
+ })
177
+ return flows
178
+
179
+
180
+ def build_cross_repo_flows(
181
+ records: list[dict[str, Any]],
182
+ touches: list[PiiTouchpoint],
183
+ *,
184
+ field_key: str = "phone",
185
+ ) -> list[dict[str, Any]]:
186
+ """Build queue + HTTP cross-repo flows for a field between repos that touch it."""
187
+ material_repos = _material_repos_with_field(touches, field_key)
188
+ if not material_repos:
189
+ return []
190
+ strong_repos = _strong_material_repos_with_field(touches, field_key)
191
+ return (
192
+ _build_queue_flows(records, field_key=field_key, material_repos=material_repos)
193
+ + _build_http_flows(records, field_key=field_key, strong_repos=strong_repos)
194
+ )
195
+
196
+
197
+ def write_pii_cross_repo_graph(
198
+ metabase_root: Path,
199
+ touches: list[PiiTouchpoint],
200
+ repo_jsons: list[Path] | None = None,
201
+ *,
202
+ field_key: str = "phone",
203
+ ) -> list[dict[str, Any]]:
204
+ """Write the cross-repo field-flow markdown + jsonl and return the flows."""
205
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
206
+ phone_repos = _material_repos_with_field(touches, field_key)
207
+ strong_repos = _strong_material_repos_with_field(touches, field_key)
208
+ flows = build_cross_repo_flows(records, touches, field_key=field_key)
209
+
210
+ graphs_dir = metabase_root / "graphs"
211
+ graphs_dir.mkdir(parents=True, exist_ok=True)
212
+
213
+ jsonl_path = graphs_dir / f"pii-{field_key}-cross-repo.jsonl"
214
+ with jsonl_path.open("w", encoding="utf-8") as fh:
215
+ for row in flows:
216
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
217
+
218
+ queue_rows = [f for f in flows if f["kind"] == "queue"]
219
+ http_rows = [f for f in flows if f["kind"] == "http"]
220
+
221
+ md: list[str] = [
222
+ f"# Cross-repo `{for_markdown(field_key, max_len=80)}` flows\n",
223
+ UNTRUSTED_CONTENT_NOTICE,
224
+ "_Messaging hops require a material field touchpoint; HTTP hops require "
225
+ "**strong** touchpoints (log/store/queue) on both sides plus a "
226
+ "non-generic path (`/metrics`, `/health`, etc. excluded). "
227
+ "**high**/**openapi** service-call edges only._\n",
228
+ f"\n**Repos with material `{field_key}` touchpoints:** "
229
+ f"{len(phone_repos)} "
230
+ f"(**strong** for HTTP: {len(strong_repos)}; "
231
+ f"of {len(_repos_with_field(touches, field_key))} with any touchpoint).\n",
232
+ f"\n**Cross-repo hops:** {len(flows)} "
233
+ f"({len(queue_rows)} queue, {len(http_rows)} HTTP).\n",
234
+ ]
235
+
236
+ if queue_rows:
237
+ md.append("\n## Messaging hops\n")
238
+ md.append(
239
+ md_table(
240
+ ["Producer", "Topic", "Consumer", "Confidence"],
241
+ [
242
+ [
243
+ r["source_repo"],
244
+ r["via"],
245
+ r["target_repo"],
246
+ r["confidence"],
247
+ ]
248
+ for r in queue_rows[:MAX_FLOW_ROWS]
249
+ ],
250
+ ),
251
+ )
252
+ if http_rows:
253
+ md.append("\n## HTTP hops (both repos touch field)\n")
254
+ md.append(
255
+ md_table(
256
+ ["Caller", "Target path", "Callee", "Confidence"],
257
+ [
258
+ [
259
+ r["source_repo"],
260
+ r["via"],
261
+ r["target_repo"],
262
+ r["confidence"],
263
+ ]
264
+ for r in http_rows[:MAX_FLOW_ROWS]
265
+ ],
266
+ ),
267
+ )
268
+
269
+ if not flows:
270
+ md.append(
271
+ "\n_No cross-repo hops found. Extend queue/http extraction or "
272
+ "re-run after more repos expose `queue-pub`/`queue-sub` nodes._\n",
273
+ )
274
+
275
+ tail = max(0, len(flows) - MAX_FLOW_ROWS)
276
+ if tail:
277
+ md.append(f"\n_{tail} more rows in `pii-{field_key}-cross-repo.jsonl`._\n")
278
+
279
+ (graphs_dir / f"pii-{field_key}-cross-repo.md").write_text(
280
+ "\n".join(md),
281
+ encoding="utf-8",
282
+ )
283
+ return flows