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.
- src2sink/__init__.py +1 -0
- src2sink/aggregators/__init__.py +3 -0
- src2sink/aggregators/api_client_discovery.py +259 -0
- src2sink/aggregators/auth_cards.py +116 -0
- src2sink/aggregators/crypto_cards.py +132 -0
- src2sink/aggregators/data_stores.py +125 -0
- src2sink/aggregators/graphs.py +36 -0
- src2sink/aggregators/index_v2.py +138 -0
- src2sink/aggregators/library_source_map.py +188 -0
- src2sink/aggregators/openapi_discovery.py +113 -0
- src2sink/aggregators/openapi_edges.py +30 -0
- src2sink/aggregators/openapi_match.py +81 -0
- src2sink/aggregators/openapi_models.py +25 -0
- src2sink/aggregators/openapi_report.py +75 -0
- src2sink/aggregators/payload_producers.py +369 -0
- src2sink/aggregators/phase3.py +30 -0
- src2sink/aggregators/pii_cross_repo.py +283 -0
- src2sink/aggregators/pii_flow_v2.py +118 -0
- src2sink/aggregators/pii_lifecycle.py +19 -0
- src2sink/aggregators/pii_lifecycle_report.py +121 -0
- src2sink/aggregators/pii_touchpoint_collect.py +134 -0
- src2sink/aggregators/pii_touchpoint_utils.py +96 -0
- src2sink/aggregators/queues.py +141 -0
- src2sink/aggregators/ropa.py +190 -0
- src2sink/aggregators/service_call_collect.py +240 -0
- src2sink/aggregators/service_call_index.py +31 -0
- src2sink/aggregators/service_call_models.py +17 -0
- src2sink/aggregators/service_call_report.py +118 -0
- src2sink/aggregators/service_calls.py +14 -0
- src2sink/aggregators/taint_buckets.py +74 -0
- src2sink/aggregators/taint_catalogs.py +35 -0
- src2sink/aggregators/taint_writers.py +369 -0
- src2sink/aggregators/traces_index.py +103 -0
- src2sink/build_metabase_v2.py +807 -0
- src2sink/constants.py +44 -0
- src2sink/curate_internal_libraries.py +135 -0
- src2sink/extractors/__init__.py +17 -0
- src2sink/extractors/ast_walk.py +131 -0
- src2sink/extractors/base.py +59 -0
- src2sink/extractors/config.py +224 -0
- src2sink/extractors/file_context.py +35 -0
- src2sink/extractors/go.py +3 -0
- src2sink/extractors/http_out.py +108 -0
- src2sink/extractors/java.py +3 -0
- src2sink/extractors/javascript.py +3 -0
- src2sink/extractors/kotlin.py +3 -0
- src2sink/extractors/node_factory.py +65 -0
- src2sink/extractors/patterns.py +145 -0
- src2sink/extractors/python_extractor.py +3 -0
- src2sink/extractors/regex_extractors.py +378 -0
- src2sink/extractors/ts_extractors.py +123 -0
- src2sink/extractors/unified.py +64 -0
- src2sink/graph_common.py +213 -0
- src2sink/internal_groups.py +142 -0
- src2sink/known_api_clients.py +108 -0
- src2sink/library_taint_java.py +92 -0
- src2sink/limits.py +171 -0
- src2sink/models/__init__.py +3 -0
- src2sink/models/auth_model.py +3 -0
- src2sink/models/crypto_agility.py +3 -0
- src2sink/models/pii_lifecycle.py +92 -0
- src2sink/models/ropa.py +37 -0
- src2sink/prescreen.py +93 -0
- src2sink/py.typed +0 -0
- src2sink/record_fleet_baseline.py +81 -0
- src2sink/renderers/__init__.py +3 -0
- src2sink/renderers/markdown.py +149 -0
- src2sink/renderers/mermaid.py +3 -0
- src2sink/repo_utils.py +885 -0
- src2sink/safe_paths.py +54 -0
- src2sink/sanitize.py +107 -0
- src2sink/schema.py +57 -0
- src2sink/trace.py +558 -0
- src2sink/trace_batch.py +176 -0
- src2sink/vocabulary.py +152 -0
- src2sink-1.0.0.dist-info/METADATA +355 -0
- src2sink-1.0.0.dist-info/RECORD +81 -0
- src2sink-1.0.0.dist-info/WHEEL +5 -0
- src2sink-1.0.0.dist-info/entry_points.txt +6 -0
- src2sink-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|