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,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)
|