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,190 @@
1
+ """ROPA Article 30 view projected from PII lifecycle touchpoints (Phase 3)."""
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 .pii_lifecycle import collect_pii_touchpoints
11
+ from ..graph_common import load_v2_repo_records
12
+ from ..models.ropa import ROPA_CATEGORY_BY_CLASSIFICATION, RopaProcessingActivity
13
+ from ..renderers.markdown import md_table
14
+
15
+ MAX_MD_ROWS = 300
16
+
17
+
18
+ def _group_touches(touches: list[Any]) -> dict[tuple[str, str, str], dict[str, Any]]:
19
+ """Group PII touchpoints by (ROPA category, classification, repo) with stage flags."""
20
+ grouped: dict[tuple[str, str, str], dict[str, Any]] = {}
21
+ for touch in touches:
22
+ category = ROPA_CATEGORY_BY_CLASSIFICATION.get(
23
+ touch.pii_classification, "Unclassified personal data"
24
+ )
25
+ key = (category, touch.pii_classification, touch.repo)
26
+ bucket = grouped.setdefault(key, {
27
+ "field_keys": set(), "stages": set(),
28
+ "store": False, "log": False, "transmit": False,
29
+ })
30
+ bucket["field_keys"].add(touch.field_key)
31
+ bucket["stages"].add(touch.stage)
32
+ if touch.stage in ("store", "log", "transmit"):
33
+ bucket[touch.stage] = True
34
+ return grouped
35
+
36
+
37
+ def _ropa_purposes(bucket: dict[str, Any]) -> list[str]:
38
+ """Infer ROPA processing purposes from the touchpoint bucket's stages."""
39
+ purposes: list[str] = []
40
+ if "collect" in bucket["stages"] or "process" in bucket["stages"]:
41
+ purposes.append("Service delivery / API processing")
42
+ if bucket["store"]:
43
+ purposes.append("Persistence")
44
+ if bucket["log"]:
45
+ purposes.append("Operations and diagnostics (logging)")
46
+ if bucket["transmit"]:
47
+ purposes.append("Inter-service transmission")
48
+ return purposes or ["Code references (stage unclear)"]
49
+
50
+
51
+ def _ropa_security(bucket: dict[str, Any]) -> list[str]:
52
+ """Infer ROPA security-measure notes from the touchpoint bucket's stages."""
53
+ security: list[str] = []
54
+ if "encrypt" in bucket["stages"]:
55
+ security.append("Crypto operations near field (static)")
56
+ if bucket["log"]:
57
+ security.append("Review logging sinks for field")
58
+ else:
59
+ security.append("Logging exposure not detected in static scan")
60
+ return security
61
+
62
+
63
+ def build_ropa_activities(
64
+ touches: list[Any],
65
+ ) -> list[RopaProcessingActivity]:
66
+ """Group touchpoints into repo × ROPA category processing activities."""
67
+ grouped = _group_touches(touches)
68
+
69
+ activities: list[RopaProcessingActivity] = []
70
+ for (category, pii_class, repo), bucket in sorted(grouped.items()):
71
+ purposes = _ropa_purposes(bucket)
72
+ security = _ropa_security(bucket)
73
+ retention = (
74
+ "See repo config / data-store graph"
75
+ if bucket["store"]
76
+ else "Not inferred from static scan"
77
+ )
78
+
79
+ activities.append(
80
+ RopaProcessingActivity(
81
+ category=category,
82
+ pii_classification=pii_class,
83
+ repo=repo,
84
+ purposes=purposes,
85
+ data_subjects="Customers and end users (inferred)",
86
+ recipients="Internal services" if bucket["transmit"] else "Not inferred",
87
+ retention_hint=retention,
88
+ security_measures=security,
89
+ field_keys=sorted(bucket["field_keys"])[:20],
90
+ ),
91
+ )
92
+ return activities
93
+
94
+
95
+ def write_ropa_view(
96
+ metabase_root: Path,
97
+ repo_jsons: list[Path] | None = None,
98
+ *,
99
+ touches: list[Any] | None = None,
100
+ ) -> list[RopaProcessingActivity]:
101
+ """Write the ROPA Article 30 markdown + jsonl view and return the activities."""
102
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
103
+ if touches is None:
104
+ touches = collect_pii_touchpoints(records)
105
+ activities = build_ropa_activities(touches)
106
+
107
+ ropa_dir = metabase_root / "ropa"
108
+ ropa_dir.mkdir(parents=True, exist_ok=True)
109
+
110
+ jsonl_path = ropa_dir / "processing-activities.jsonl"
111
+ with jsonl_path.open("w", encoding="utf-8") as fh:
112
+ for act in activities:
113
+ fh.write(json.dumps(act.to_dict(), ensure_ascii=False) + "\n")
114
+
115
+ by_category: dict[str, list[RopaProcessingActivity]] = defaultdict(list)
116
+ for act in activities:
117
+ by_category[act.category].append(act)
118
+
119
+ md: list[str] = [
120
+ "# Categories of personal data (ROPA Article 30 view)\n",
121
+ "_Projected from v2 PII lifecycle touchpoints. For legal ROPA, validate "
122
+ "purposes, lawful basis, and retention with product owners._\n",
123
+ f"\n_Total processing activities (repo × category): **{len(activities)}**._\n",
124
+ ]
125
+
126
+ phone_acts = [
127
+ a for a in activities
128
+ if "phone" in a.field_keys
129
+ ]
130
+ if phone_acts:
131
+ md.append("\n## Worked example: phone number processing\n")
132
+ md.append(
133
+ md_table(
134
+ ["Repo", "Category", "Purposes", "Store", "Log", "Transmit"],
135
+ [
136
+ [
137
+ a.repo,
138
+ a.category,
139
+ "; ".join(a.purposes[:2]),
140
+ "yes" if "Persistence" in a.purposes else "—",
141
+ "yes" if "logging" in " ".join(a.purposes).lower() else "—",
142
+ "yes" if a.recipients != "Not inferred" else "—",
143
+ ]
144
+ for a in sorted(phone_acts, key=lambda x: x.repo)[:40]
145
+ ],
146
+ ),
147
+ )
148
+
149
+ md.append("\n## By category (summary)\n")
150
+ md.append(
151
+ md_table(
152
+ ["Category", "Activities", "Distinct repos"],
153
+ [
154
+ [
155
+ cat,
156
+ str(len(acts)),
157
+ str(len({a.repo for a in acts})),
158
+ ]
159
+ for cat, acts in sorted(
160
+ by_category.items(),
161
+ key=lambda x: -len(x[1]),
162
+ )[:30]
163
+ ],
164
+ ),
165
+ )
166
+
167
+ md.append("\n## All activities (sample)\n")
168
+ md.append(
169
+ md_table(
170
+ ["Category", "Repo", "Fields", "Purposes"],
171
+ [
172
+ [
173
+ a.category,
174
+ a.repo,
175
+ ", ".join(a.field_keys[:5]),
176
+ "; ".join(a.purposes[:2]),
177
+ ]
178
+ for a in activities[:MAX_MD_ROWS]
179
+ ],
180
+ ),
181
+ )
182
+ tail = max(0, len(activities) - MAX_MD_ROWS)
183
+ if tail:
184
+ md.append(f"\n_{tail} more rows in `processing-activities.jsonl`._\n")
185
+
186
+ (ropa_dir / "categories-of-personal-data.md").write_text(
187
+ "\n".join(md),
188
+ encoding="utf-8",
189
+ )
190
+ return activities
@@ -0,0 +1,240 @@
1
+ """Collect cross-repo service-call edges from v2 flow nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..graph_common import (
8
+ build_repo_alias_index,
9
+ extract_urls_and_paths,
10
+ iter_nodes,
11
+ match_path_in_inbound_index,
12
+ repo_id,
13
+ resolve_repo_for_host,
14
+ )
15
+ from .service_call_index import InboundRow, build_inbound_index
16
+ from .service_call_models import CallEdge
17
+
18
+
19
+ def _append_path_edge(
20
+ edges: list[CallEdge],
21
+ seen: set[tuple[str, str, str, str]],
22
+ *,
23
+ src: str,
24
+ tgt_repo: str,
25
+ tgt_path: str,
26
+ conf: str,
27
+ evidence: str,
28
+ ref: str,
29
+ ) -> None:
30
+ """Append a deduplicated path-level call edge unless it is a self-edge."""
31
+ if tgt_repo == src:
32
+ return
33
+ key = (src, tgt_repo, tgt_path, conf)
34
+ if key in seen:
35
+ return
36
+ seen.add(key)
37
+ edges.append(CallEdge(
38
+ source_repo=src,
39
+ target_repo=tgt_repo,
40
+ target_path=tgt_path,
41
+ confidence=conf,
42
+ evidence=evidence,
43
+ refs=[ref],
44
+ ))
45
+
46
+
47
+ def _append_host_edge(
48
+ edges: list[CallEdge],
49
+ seen: set[tuple[str, str, str, str]],
50
+ *,
51
+ src: str,
52
+ tgt_repo: str,
53
+ evidence: str,
54
+ ref: str,
55
+ ) -> None:
56
+ """Append a deduplicated low-confidence host-level call edge unless self-edge."""
57
+ if tgt_repo == src:
58
+ return
59
+ key = (src, tgt_repo, "*", "low")
60
+ if key in seen:
61
+ return
62
+ seen.add(key)
63
+ edges.append(CallEdge(
64
+ source_repo=src,
65
+ target_repo=tgt_repo,
66
+ target_path="*",
67
+ confidence="low",
68
+ evidence=evidence,
69
+ refs=[ref],
70
+ ))
71
+
72
+
73
+ def _paths_from_http_out_detail(detail: dict[str, Any], raw: str) -> tuple[list[str], list[str]]:
74
+ """Extract (hosts, paths) from an http-out node detail plus its raw text."""
75
+ hosts = [detail["host"]] if detail.get("host") else []
76
+ paths: list[str] = list(detail.get("paths") or [])
77
+ if detail.get("path"):
78
+ paths.insert(0, detail["path"])
79
+ extra_hosts, extra_paths = extract_urls_and_paths(raw)
80
+ hosts.extend(h for h in extra_hosts if h not in hosts)
81
+ paths.extend(p for p in extra_paths if p not in paths)
82
+ return hosts, paths
83
+
84
+
85
+ def _collect_http_out_edges(
86
+ records: list[dict[str, Any]],
87
+ inbound: dict[str, list[InboundRow]],
88
+ alias_to_repo: dict[str, str],
89
+ seen: set[tuple[str, str, str, str]],
90
+ edges: list[CallEdge],
91
+ broken: list[dict[str, str]],
92
+ ) -> None:
93
+ """Collect edges from http-out sink nodes, recording unmatched refs in broken."""
94
+ for data in records:
95
+ src = repo_id(data)
96
+ for node in iter_nodes(data):
97
+ if node.get("family") != "http-out" or node.get("kind") != "sink":
98
+ continue
99
+ detail = node.get("detail") or {}
100
+ raw = detail.get("raw", "")
101
+ ref = f"{node.get('file')}:{node.get('line')}"
102
+ hosts, paths = _paths_from_http_out_detail(detail, raw)
103
+
104
+ matched = False
105
+ for path in paths:
106
+ targets, conf = match_path_in_inbound_index(path, inbound)
107
+ for tgt_repo, tgt_path, _method, _in_ref in targets:
108
+ _append_path_edge(
109
+ edges,
110
+ seen,
111
+ src=src,
112
+ tgt_repo=tgt_repo,
113
+ tgt_path=tgt_path,
114
+ conf=conf,
115
+ evidence=f"path literal {path!r} in http-out",
116
+ ref=ref,
117
+ )
118
+ matched = True
119
+
120
+ for host in hosts:
121
+ tgt_repo = resolve_repo_for_host(host, alias_to_repo)
122
+ if not tgt_repo:
123
+ if not matched:
124
+ broken.append({
125
+ "source_repo": src,
126
+ "host": host,
127
+ "ref": ref,
128
+ "raw": raw[:120],
129
+ })
130
+ continue
131
+ _append_host_edge(
132
+ edges,
133
+ seen,
134
+ src=src,
135
+ tgt_repo=tgt_repo,
136
+ evidence=f"host {host!r} in http-out",
137
+ ref=ref,
138
+ )
139
+
140
+
141
+ def _collect_reference_edges(
142
+ records: list[dict[str, Any]],
143
+ inbound: dict[str, list[InboundRow]],
144
+ alias_to_repo: dict[str, str],
145
+ seen: set[tuple[str, str, str, str]],
146
+ edges: list[CallEdge],
147
+ ) -> None:
148
+ """Match path/URL literals in any node detail (config, tests, clients)."""
149
+ for data in records:
150
+ src = repo_id(data)
151
+ for node in iter_nodes(data):
152
+ detail = node.get("detail") or {}
153
+ blob = " ".join(
154
+ str(v) for v in detail.values() if isinstance(v, str)
155
+ )
156
+ if not blob:
157
+ continue
158
+ ref = f"{node.get('file')}:{node.get('line')}"
159
+ hosts, paths = extract_urls_and_paths(blob)
160
+ for path in paths:
161
+ targets, conf = match_path_in_inbound_index(path, inbound)
162
+ for tgt_repo, tgt_path, _, _ in targets:
163
+ _append_path_edge(
164
+ edges,
165
+ seen,
166
+ src=src,
167
+ tgt_repo=tgt_repo,
168
+ tgt_path=tgt_path,
169
+ conf=conf,
170
+ evidence=f"path/url reference in {node.get('family')}",
171
+ ref=ref,
172
+ )
173
+ for host in hosts:
174
+ tgt_repo = resolve_repo_for_host(host, alias_to_repo)
175
+ if tgt_repo:
176
+ _append_host_edge(
177
+ edges,
178
+ seen,
179
+ src=src,
180
+ tgt_repo=tgt_repo,
181
+ evidence=f"host reference in {node.get('family')}",
182
+ ref=ref,
183
+ )
184
+
185
+
186
+ def collect_service_edges(
187
+ records: list[dict[str, Any]],
188
+ ) -> tuple[list[CallEdge], list[dict[str, str]]]:
189
+ """Collect all cross-repo call edges; returns (edges, unmatched outbound refs)."""
190
+ inbound = build_inbound_index(records)
191
+ alias_to_repo = build_repo_alias_index(records)
192
+ edges: list[CallEdge] = []
193
+ broken: list[dict[str, str]] = []
194
+ seen: set[tuple[str, str, str, str]] = set()
195
+
196
+ _collect_http_out_edges(records, inbound, alias_to_repo, seen, edges, broken)
197
+ _collect_reference_edges(records, inbound, alias_to_repo, seen, edges)
198
+ return edges, broken
199
+
200
+
201
+ def merge_openapi_edges(
202
+ edges: list[CallEdge],
203
+ records: list[dict[str, Any]],
204
+ repos_root: Any,
205
+ ) -> int:
206
+ """Append OpenAPI-matched edges; returns count added."""
207
+ from pathlib import Path
208
+
209
+ root = Path(repos_root)
210
+ if not root.is_dir():
211
+ return 0
212
+ from .openapi_edges import (
213
+ build_openapi_inbound_index,
214
+ discover_openapi_specs,
215
+ match_http_out_to_openapi,
216
+ )
217
+
218
+ specs = discover_openapi_specs(root)
219
+ if not specs:
220
+ return 0
221
+ inbound = build_openapi_inbound_index(specs)
222
+ alias_to_repo = build_repo_alias_index(records)
223
+ rows = match_http_out_to_openapi(records, inbound, alias_to_repo)
224
+ seen = {(e.source_repo, e.target_repo, e.target_path, e.confidence) for e in edges}
225
+ added = 0
226
+ for row in rows:
227
+ key = (row["source_repo"], row["target_repo"], row["target_path"], "openapi")
228
+ if key in seen:
229
+ continue
230
+ seen.add(key)
231
+ edges.append(CallEdge(
232
+ source_repo=row["source_repo"],
233
+ target_repo=row["target_repo"],
234
+ target_path=row["target_path"],
235
+ confidence="openapi",
236
+ evidence=row.get("evidence", "OpenAPI path match"),
237
+ refs=row.get("refs", []),
238
+ ))
239
+ added += 1
240
+ return added
@@ -0,0 +1,31 @@
1
+ """Inbound route index for cross-repo service-call matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from typing import Any
7
+
8
+ from ..graph_common import iter_nodes, normalize_path_template as norm_path, repo_id
9
+
10
+ # (target_repo, inbound_path, http_method, file:line)
11
+ InboundRow = tuple[str, str, str, str]
12
+
13
+
14
+ def build_inbound_index(
15
+ records: list[dict[str, Any]],
16
+ ) -> dict[str, list[InboundRow]]:
17
+ """Map normalised path template -> inbound http-in nodes."""
18
+ index: dict[str, list[InboundRow]] = defaultdict(list)
19
+ for data in records:
20
+ rid = repo_id(data)
21
+ for node in iter_nodes(data):
22
+ if node.get("family") != "http-in" or node.get("kind") != "source":
23
+ continue
24
+ path = (node.get("detail") or {}).get("path", "")
25
+ if not path or path == "?":
26
+ continue
27
+ key = norm_path(path)
28
+ ref = f"{node.get('file')}:{node.get('line')}"
29
+ method = (node.get("detail") or {}).get("method", "?")
30
+ index[key].append((rid, path, method, ref))
31
+ return index
@@ -0,0 +1,17 @@
1
+ """Data types for service-call graph edges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class CallEdge:
10
+ """A cross-repo service-call edge from a source repo to a target route."""
11
+
12
+ source_repo: str
13
+ target_repo: str
14
+ target_path: str
15
+ confidence: str
16
+ evidence: str
17
+ refs: list[str] = field(default_factory=list)
@@ -0,0 +1,118 @@
1
+ """Render service-call graph markdown and JSONL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections import Counter
8
+ from pathlib import Path
9
+
10
+ from ..graph_common import load_v2_repo_records
11
+ from ..renderers.markdown import md_table
12
+ from ..sanitize import UNTRUSTED_CONTENT_NOTICE, for_mermaid_label
13
+
14
+ from .service_call_collect import collect_service_edges, merge_openapi_edges
15
+ from .service_call_models import CallEdge
16
+
17
+
18
+ def _render_mermaid_section(edges: list[CallEdge]) -> list[str]:
19
+ """Return markdown lines for a Mermaid flowchart of the top cross-repo pairs."""
20
+ top = Counter((e.source_repo, e.target_repo) for e in edges).most_common(40)
21
+ if not top:
22
+ return []
23
+ lines = [
24
+ "\n## Mermaid (top cross-repo pairs)\n\n```mermaid\nflowchart LR\n",
25
+ ]
26
+ for (src, dst), _ in top:
27
+ sid = re.sub(r"[^A-Za-z0-9_]", "_", src)[:40]
28
+ did = re.sub(r"[^A-Za-z0-9_]", "_", dst)[:40]
29
+ # ids are slugified above; labels carry the raw repo id — neutralise them.
30
+ lines.append(f' {sid}["{for_mermaid_label(src)}"] --> {did}["{for_mermaid_label(dst)}"]\n')
31
+ lines.append("```\n")
32
+ return lines
33
+
34
+
35
+ def write_service_call_graph(
36
+ metabase_root: Path,
37
+ repo_jsons: list[Path],
38
+ *,
39
+ repos_root: Path | None = None,
40
+ ) -> None:
41
+ """Write the service-call graph markdown report and edges JSONL to graphs/."""
42
+ records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
43
+ edges, broken = collect_service_edges(records)
44
+ if repos_root:
45
+ merge_openapi_edges(edges, records, repos_root)
46
+
47
+ out_dir = metabase_root / "graphs"
48
+ out_dir.mkdir(parents=True, exist_ok=True)
49
+
50
+ by_conf = Counter(e.confidence for e in edges)
51
+ md: list[str] = [
52
+ "# Service-call graph (v2)\n",
53
+ UNTRUSTED_CONTENT_NOTICE,
54
+ "_Cross-repo edges from `http-out` call sites matched to `http-in` path "
55
+ "templates. Confidence: **high** = same normalised path; **medium** = "
56
+ "prefix/template overlap; **low** = hostname hints only; **openapi** = "
57
+ "path matched to a discovered OpenAPI/Swagger spec._\n",
58
+ "\n> Regex-derived; validate against OpenAPI / Helm before acting on "
59
+ "low-confidence edges.\n",
60
+ "\n## Summary\n",
61
+ md_table(
62
+ ["Confidence", "Edge count"],
63
+ [[k, str(v)] for k, v in by_conf.most_common()],
64
+ ),
65
+ ]
66
+
67
+ detail = sorted(
68
+ edges,
69
+ key=lambda e: (e.confidence, e.target_repo, e.source_repo),
70
+ )
71
+ md.append("\n## Matched edges (sampled)\n")
72
+ md.append(md_table(
73
+ ["Source", "Target", "Path", "Confidence", "Evidence"],
74
+ [
75
+ [
76
+ e.source_repo,
77
+ e.target_repo,
78
+ e.target_path,
79
+ e.confidence,
80
+ e.evidence[:100],
81
+ ]
82
+ for e in detail[:500]
83
+ ],
84
+ ))
85
+ if len(detail) > 500:
86
+ md.append(f"\n_{len(detail) - 500} additional edges omitted._\n")
87
+
88
+ md.extend(_render_mermaid_section(edges))
89
+
90
+ md.append("\n## Broken / unmatched outbound refs\n")
91
+ md.append(
92
+ "_http-out sites with a resolvable host or path that did not match any "
93
+ "indexed inbound route (sampled)._\n\n",
94
+ )
95
+ if broken:
96
+ md.append(md_table(
97
+ ["Source repo", "Host / hint", "File:line", "Raw (trimmed)"],
98
+ [
99
+ [b["source_repo"], b.get("host", "?"), b["ref"], b["raw"]]
100
+ for b in broken[:200]
101
+ ],
102
+ ))
103
+ else:
104
+ md.append("_No unmatched hosts in sample._\n")
105
+
106
+ (out_dir / "service-call-graph.md").write_text("\n".join(md), encoding="utf-8")
107
+
108
+ jsonl = out_dir / "service-call-edges.jsonl"
109
+ with jsonl.open("w", encoding="utf-8") as fh:
110
+ for e in edges:
111
+ fh.write(json.dumps({
112
+ "source_repo": e.source_repo,
113
+ "target_repo": e.target_repo,
114
+ "target_path": e.target_path,
115
+ "confidence": e.confidence,
116
+ "evidence": e.evidence,
117
+ "refs": e.refs,
118
+ }, ensure_ascii=False) + "\n")
@@ -0,0 +1,14 @@
1
+ """Service-call graph aggregator — v2 http-in / http-out cross-repo matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .service_call_collect import collect_service_edges, merge_openapi_edges
6
+ from .service_call_models import CallEdge
7
+ from .service_call_report import write_service_call_graph
8
+
9
+ __all__ = [
10
+ "CallEdge",
11
+ "collect_service_edges",
12
+ "merge_openapi_edges",
13
+ "write_service_call_graph",
14
+ ]
@@ -0,0 +1,74 @@
1
+ """Collect v2 flow nodes into catalogue buckets before markdown/jsonl export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class TaintCatalogueBuckets:
13
+ """Grouped node records keyed by taint family."""
14
+
15
+ sql_sources: list[dict[str, Any]] = field(default_factory=list)
16
+ sql_sinks: list[dict[str, Any]] = field(default_factory=list)
17
+ file_sinks: list[dict[str, Any]] = field(default_factory=list)
18
+ http_sinks: list[dict[str, Any]] = field(default_factory=list)
19
+ pii_sources: list[dict[str, Any]] = field(default_factory=list)
20
+ data_class_fields: list[dict[str, Any]] = field(default_factory=list)
21
+ pii_sinks: list[dict[str, Any]] = field(default_factory=list)
22
+ crypto_ops: list[dict[str, Any]] = field(default_factory=list)
23
+ raw_payload: list[dict[str, Any]] = field(default_factory=list)
24
+ config_stores: list[dict[str, Any]] = field(default_factory=list)
25
+ config_security: list[dict[str, Any]] = field(default_factory=list)
26
+ crypto_config: list[dict[str, Any]] = field(default_factory=list)
27
+
28
+
29
+ def collect_taint_buckets(repo_jsons: list[Path]) -> TaintCatalogueBuckets:
30
+ """Walk per-repo v2 JSON files and partition nodes into export buckets."""
31
+ buckets = TaintCatalogueBuckets()
32
+ for jp in repo_jsons:
33
+ try:
34
+ data = json.loads(jp.read_text(encoding="utf-8"))
35
+ except (OSError, json.JSONDecodeError):
36
+ continue
37
+ repo_id = f"{data['group']}/{data['name']}"
38
+ for node in data.get("nodes", []):
39
+ _route_node(buckets, node, repo_id)
40
+ return buckets
41
+
42
+
43
+ # Families that map 1:1 to a bucket regardless of kind.
44
+ _SIMPLE_FAMILY_BUCKET = {
45
+ "file": "file_sinks",
46
+ "http-out": "http_sinks",
47
+ "pii-field": "pii_sources",
48
+ "data-class-field": "data_class_fields",
49
+ "crypto-algorithm": "crypto_ops",
50
+ "raw-code-payload": "raw_payload",
51
+ "config-security": "config_security",
52
+ "crypto-config": "crypto_config",
53
+ }
54
+
55
+
56
+ def _route_node(buckets: TaintCatalogueBuckets, node: dict[str, Any], repo_id: str) -> None:
57
+ """Append a repo-tagged copy of the node to the bucket for its family/kind."""
58
+ rec = {**node, "repo": repo_id}
59
+ family = node.get("family", "")
60
+ kind = node.get("kind", "")
61
+
62
+ if family == "sql":
63
+ if kind == "source":
64
+ buckets.sql_sources.append(rec)
65
+ elif kind == "sink" and node.get("detail", {}).get("execution", True):
66
+ buckets.sql_sinks.append(rec)
67
+ elif family in ("pii-log", "pii-storage"):
68
+ buckets.pii_sinks.append(rec)
69
+ elif family == "data-store" and kind == "store":
70
+ buckets.config_stores.append(rec)
71
+ else:
72
+ attr = _SIMPLE_FAMILY_BUCKET.get(family)
73
+ if attr:
74
+ getattr(buckets, attr).append(rec)