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,35 @@
|
|
|
1
|
+
"""Cross-repo taint catalogues from v2 flow nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .taint_buckets import collect_taint_buckets
|
|
8
|
+
from .taint_writers import (
|
|
9
|
+
MAX_MD_ROWS,
|
|
10
|
+
MAX_PII_MD_BYTES,
|
|
11
|
+
write_config_catalogues,
|
|
12
|
+
write_crypto_and_payload_catalogues,
|
|
13
|
+
write_file_and_http_catalogues,
|
|
14
|
+
write_pii_catalogues,
|
|
15
|
+
write_sql_catalogues,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MAX_MD_ROWS",
|
|
20
|
+
"MAX_PII_MD_BYTES",
|
|
21
|
+
"aggregate_taint_catalogs_v2",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def aggregate_taint_catalogs_v2(metabase_root: Path, repo_jsons: list[Path]) -> None:
|
|
26
|
+
"""Collect nodes from v2 JSONs and write all `metabase/taint/*` catalogues."""
|
|
27
|
+
taint_dir = metabase_root / "taint"
|
|
28
|
+
taint_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
buckets = collect_taint_buckets(repo_jsons)
|
|
31
|
+
write_sql_catalogues(taint_dir, buckets)
|
|
32
|
+
write_file_and_http_catalogues(taint_dir, buckets)
|
|
33
|
+
write_pii_catalogues(taint_dir, buckets)
|
|
34
|
+
write_crypto_and_payload_catalogues(taint_dir, buckets)
|
|
35
|
+
write_config_catalogues(taint_dir, buckets)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Write individual taint catalogue markdown + jsonl artefacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .taint_buckets import TaintCatalogueBuckets
|
|
11
|
+
from ..renderers.markdown import md_table
|
|
12
|
+
from ..sanitize import UNTRUSTED_CONTENT_NOTICE
|
|
13
|
+
|
|
14
|
+
MAX_MD_ROWS = 500
|
|
15
|
+
MAX_PII_MD_BYTES = 4_000_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _hierarchical_section(
|
|
19
|
+
title: str,
|
|
20
|
+
intro: str,
|
|
21
|
+
summary_rows: list[list[str]],
|
|
22
|
+
detail_rows: list[list[str]],
|
|
23
|
+
detail_headers: list[str],
|
|
24
|
+
tail_count: int,
|
|
25
|
+
jsonl_path: Path,
|
|
26
|
+
) -> list[str]:
|
|
27
|
+
"""Build markdown lines for a title + intro + summary table + sampled detail table."""
|
|
28
|
+
md: list[str] = [f"# {title}\n", intro, UNTRUSTED_CONTENT_NOTICE, "\n## Summary\n"]
|
|
29
|
+
md.append(md_table(summary_rows[0], summary_rows[1:]))
|
|
30
|
+
md.append("\n## Detail (sampled)\n")
|
|
31
|
+
md.append(md_table(detail_headers, detail_rows[:MAX_MD_ROWS]))
|
|
32
|
+
if tail_count > 0:
|
|
33
|
+
md.append(f"\n_{tail_count} additional rows — see `{jsonl_path.name}`._\n")
|
|
34
|
+
return md
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_jsonl(path: Path, records: list[dict[str, Any]]) -> None:
|
|
38
|
+
"""Write records as newline-delimited JSON, creating parent dirs."""
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
41
|
+
for rec in records:
|
|
42
|
+
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_sql_catalogues(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
46
|
+
"""Write SQL sources, execution sinks, and legacy sql-sinks stub catalogues."""
|
|
47
|
+
src_by_kind = Counter(r.get("detail", {}).get("pattern", "?") for r in buckets.sql_sources)
|
|
48
|
+
md = _hierarchical_section(
|
|
49
|
+
"SQL sources — string construction patterns",
|
|
50
|
+
"_Heuristic: SQL-shaped strings built via concatenation or interpolation "
|
|
51
|
+
"(not execution sinks). Pair with `sql-execution-sinks.md` for full flow._\n",
|
|
52
|
+
[["Pattern", "Count"], *[[k, str(v)] for k, v in src_by_kind.most_common()]],
|
|
53
|
+
[
|
|
54
|
+
[
|
|
55
|
+
r["repo"],
|
|
56
|
+
r.get("detail", {}).get("pattern", ""),
|
|
57
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
58
|
+
str(r.get("detail", {}).get("snippet", ""))[:100],
|
|
59
|
+
]
|
|
60
|
+
for r in buckets.sql_sources
|
|
61
|
+
],
|
|
62
|
+
["Repo", "Pattern", "File:line", "Snippet"],
|
|
63
|
+
max(0, len(buckets.sql_sources) - MAX_MD_ROWS),
|
|
64
|
+
taint_dir / "sql-sources.jsonl",
|
|
65
|
+
)
|
|
66
|
+
_write_jsonl(taint_dir / "sql-sources.jsonl", buckets.sql_sources)
|
|
67
|
+
(taint_dir / "sql-sources.md").write_text("\n".join(md), encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
sink_param = Counter(
|
|
70
|
+
"parameterised" if r.get("detail", {}).get("parameterised") else "raw"
|
|
71
|
+
for r in buckets.sql_sinks
|
|
72
|
+
)
|
|
73
|
+
md = _hierarchical_section(
|
|
74
|
+
"SQL execution sinks",
|
|
75
|
+
"_Tree-sitter + heuristic JDBC/JPA/native execution call sites "
|
|
76
|
+
"(ORM `find`/`save` excluded)._\n",
|
|
77
|
+
[["Posture", "Count"], *[[k, str(v)] for k, v in sink_param.most_common()]],
|
|
78
|
+
[
|
|
79
|
+
[
|
|
80
|
+
r["repo"],
|
|
81
|
+
r.get("detail", {}).get("symbol", ""),
|
|
82
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
83
|
+
str(r.get("detail", {}).get("raw", ""))[:100],
|
|
84
|
+
]
|
|
85
|
+
for r in buckets.sql_sinks
|
|
86
|
+
],
|
|
87
|
+
["Repo", "Symbol", "File:line", "Call"],
|
|
88
|
+
max(0, len(buckets.sql_sinks) - MAX_MD_ROWS),
|
|
89
|
+
taint_dir / "sql-execution-sinks.jsonl",
|
|
90
|
+
)
|
|
91
|
+
_write_jsonl(taint_dir / "sql-execution-sinks.jsonl", buckets.sql_sinks)
|
|
92
|
+
(taint_dir / "sql-execution-sinks.md").write_text("\n".join(md), encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
(taint_dir / "sql-sinks.md").write_text(
|
|
95
|
+
"# SQL sinks (execution)\n\n"
|
|
96
|
+
"_Renamed in v2: see `sql-execution-sinks.md` for JDBC/JPA sinks and "
|
|
97
|
+
"`sql-sources.md` for string-concat sources._\n",
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_file_and_http_catalogues(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
103
|
+
"""Write file-write/archive-extraction and outbound HTTP/RPC sink catalogues."""
|
|
104
|
+
by_op = Counter(r.get("detail", {}).get("operation", "?") for r in buckets.file_sinks)
|
|
105
|
+
md = _hierarchical_section(
|
|
106
|
+
"File-write and archive-extraction sinks",
|
|
107
|
+
"_Disk I/O sinks detected across the fleet._\n",
|
|
108
|
+
[["Operation", "Count"], *[[k, str(v)] for k, v in by_op.most_common()]],
|
|
109
|
+
[
|
|
110
|
+
[r["repo"], r.get("detail", {}).get("operation", ""), f"{r.get('file')}:{r.get('line')}"]
|
|
111
|
+
for r in buckets.file_sinks
|
|
112
|
+
],
|
|
113
|
+
["Repo", "Operation", "File:line"],
|
|
114
|
+
max(0, len(buckets.file_sinks) - MAX_MD_ROWS),
|
|
115
|
+
taint_dir / "file-sinks.jsonl",
|
|
116
|
+
)
|
|
117
|
+
_write_jsonl(taint_dir / "file-sinks.jsonl", buckets.file_sinks)
|
|
118
|
+
(taint_dir / "file-sinks.md").write_text("\n".join(md), encoding="utf-8")
|
|
119
|
+
|
|
120
|
+
md = _hierarchical_section(
|
|
121
|
+
"HTTP / RPC outbound sinks",
|
|
122
|
+
"_Client calls and URL construction (SSRF surface)._\n",
|
|
123
|
+
[["Purpose", "Count"],
|
|
124
|
+
*[[k, str(v)] for k, v in Counter(
|
|
125
|
+
r.get("detail", {}).get("purpose", "?") for r in buckets.http_sinks
|
|
126
|
+
).most_common()]],
|
|
127
|
+
[
|
|
128
|
+
[r["repo"], r.get("detail", {}).get("purpose", ""),
|
|
129
|
+
f"{r.get('file')}:{r.get('line')}", str(r.get("detail", {}).get("raw", ""))[:80]]
|
|
130
|
+
for r in buckets.http_sinks
|
|
131
|
+
],
|
|
132
|
+
["Repo", "Purpose", "File:line", "Raw"],
|
|
133
|
+
max(0, len(buckets.http_sinks) - MAX_MD_ROWS),
|
|
134
|
+
taint_dir / "http-sinks.jsonl",
|
|
135
|
+
)
|
|
136
|
+
_write_jsonl(taint_dir / "http-sinks.jsonl", buckets.http_sinks)
|
|
137
|
+
(taint_dir / "http-sinks.md").write_text("\n".join(md), encoding="utf-8")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_pii_sources(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
141
|
+
"""Write the GDPR PII-field source catalogue grouped by classification."""
|
|
142
|
+
by_class: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
143
|
+
for r in buckets.pii_sources:
|
|
144
|
+
key = r.get("pii_classification") or r.get("data_class") or "unknown"
|
|
145
|
+
by_class[key].append(r)
|
|
146
|
+
|
|
147
|
+
pii_md: list[str] = [
|
|
148
|
+
"# PII field sources (GDPR axis)\n",
|
|
149
|
+
"_Identifiers classified under `pii_classification` (`pii-field` nodes "
|
|
150
|
+
"only). Business-data-class and dangerous-payload field names are in "
|
|
151
|
+
"`dangerous-payload-fields.jsonl` — not personal data._\n\n",
|
|
152
|
+
UNTRUSTED_CONTENT_NOTICE,
|
|
153
|
+
"## By classification\n",
|
|
154
|
+
md_table(
|
|
155
|
+
["Classification", "Occurrences", "Distinct repos"],
|
|
156
|
+
[
|
|
157
|
+
[cls, str(len(recs)), str(len({r["repo"] for r in recs}))]
|
|
158
|
+
for cls, recs in sorted(by_class.items(), key=lambda x: -len(x[1]))
|
|
159
|
+
],
|
|
160
|
+
),
|
|
161
|
+
"\n## Sample occurrences (max 500)\n",
|
|
162
|
+
md_table(
|
|
163
|
+
["Repo", "Field", "PII class", "Data class", "File:line"],
|
|
164
|
+
[
|
|
165
|
+
[
|
|
166
|
+
r["repo"],
|
|
167
|
+
r.get("detail", {}).get("field_name", ""),
|
|
168
|
+
r.get("pii_classification") or "",
|
|
169
|
+
r.get("data_class") or "",
|
|
170
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
171
|
+
]
|
|
172
|
+
for r in buckets.pii_sources[:MAX_MD_ROWS]
|
|
173
|
+
],
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
if len(buckets.pii_sources) > MAX_MD_ROWS:
|
|
177
|
+
pii_md.append(f"\n_{len(buckets.pii_sources) - MAX_MD_ROWS} more in pii-sources.jsonl._\n")
|
|
178
|
+
_write_jsonl(taint_dir / "pii-sources.jsonl", buckets.pii_sources)
|
|
179
|
+
body = "\n".join(pii_md)
|
|
180
|
+
if len(body.encode()) > MAX_PII_MD_BYTES:
|
|
181
|
+
body = "\n".join(pii_md[:6]) + "\n\n_(truncated — use pii-sources.jsonl)_\n"
|
|
182
|
+
(taint_dir / "pii-sources.md").write_text(body, encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _write_dangerous_payload_fields(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
186
|
+
"""Write the dangerous-payload / business-data-class field-name catalogue."""
|
|
187
|
+
dp_by_class: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
188
|
+
for r in buckets.data_class_fields:
|
|
189
|
+
dp_by_class[r.get("data_class") or "unknown"].append(r)
|
|
190
|
+
dp_md: list[str] = [
|
|
191
|
+
"# Dangerous-payload and business-data-class field names\n",
|
|
192
|
+
UNTRUSTED_CONTENT_NOTICE,
|
|
193
|
+
"_`data-class-field` nodes: tenant content, credentials, or **dangerous-payload** "
|
|
194
|
+
"(query/sql/script/expression variables that may carry injection taint — "
|
|
195
|
+
"not GDPR PII). Full stream in `dangerous-payload-fields.jsonl`._\n\n",
|
|
196
|
+
"## By data_class\n",
|
|
197
|
+
md_table(
|
|
198
|
+
["Class", "Occurrences", "Distinct repos"],
|
|
199
|
+
[
|
|
200
|
+
[cls, str(len(recs)), str(len({r["repo"] for r in recs}))]
|
|
201
|
+
for cls, recs in sorted(dp_by_class.items(), key=lambda x: -len(x[1]))
|
|
202
|
+
],
|
|
203
|
+
),
|
|
204
|
+
"\n## Sample (max 500)\n",
|
|
205
|
+
md_table(
|
|
206
|
+
["Repo", "Field", "Data class", "File:line"],
|
|
207
|
+
[
|
|
208
|
+
[
|
|
209
|
+
r["repo"],
|
|
210
|
+
r.get("detail", {}).get("field_name", ""),
|
|
211
|
+
r.get("data_class") or "",
|
|
212
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
213
|
+
]
|
|
214
|
+
for r in buckets.data_class_fields[:MAX_MD_ROWS]
|
|
215
|
+
],
|
|
216
|
+
),
|
|
217
|
+
]
|
|
218
|
+
if len(buckets.data_class_fields) > MAX_MD_ROWS:
|
|
219
|
+
dp_md.append(
|
|
220
|
+
f"\n_{len(buckets.data_class_fields) - MAX_MD_ROWS} more in "
|
|
221
|
+
"dangerous-payload-fields.jsonl._\n",
|
|
222
|
+
)
|
|
223
|
+
_write_jsonl(taint_dir / "dangerous-payload-fields.jsonl", buckets.data_class_fields)
|
|
224
|
+
(taint_dir / "dangerous-payload-fields.md").write_text("\n".join(dp_md), encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _write_pii_sinks(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
228
|
+
"""Write the PII storage/logging sink catalogue."""
|
|
229
|
+
md = _hierarchical_section(
|
|
230
|
+
"PII sinks — storage and logging",
|
|
231
|
+
"_`pii-log`: logger calls with a PII field name near the call site. "
|
|
232
|
+
"`pii-storage`: `.save`/`.persist`/S3/email patterns; `field_name` is "
|
|
233
|
+
"set only when a PII token appears within ±120 characters (else "
|
|
234
|
+
"`field_name` is null and `confidence` is low — generic persistence, "
|
|
235
|
+
"not proven PII). See `SCHEMA.md` § `taint/pii-sinks.md`._\n",
|
|
236
|
+
[["Family", "Count"],
|
|
237
|
+
*[[str(k), str(v)] for k, v in Counter(r.get("family") for r in buckets.pii_sinks).most_common()]],
|
|
238
|
+
[
|
|
239
|
+
[
|
|
240
|
+
r["repo"],
|
|
241
|
+
r.get("family", ""),
|
|
242
|
+
r.get("detail", {}).get("field_name", ""),
|
|
243
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
244
|
+
]
|
|
245
|
+
for r in buckets.pii_sinks
|
|
246
|
+
],
|
|
247
|
+
["Repo", "Family", "Field", "File:line"],
|
|
248
|
+
max(0, len(buckets.pii_sinks) - MAX_MD_ROWS),
|
|
249
|
+
taint_dir / "pii-sinks.jsonl",
|
|
250
|
+
)
|
|
251
|
+
_write_jsonl(taint_dir / "pii-sinks.jsonl", buckets.pii_sinks)
|
|
252
|
+
(taint_dir / "pii-sinks.md").write_text("\n".join(md), encoding="utf-8")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def write_pii_catalogues(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
256
|
+
"""Write PII sources, dangerous-payload fields, and PII sink catalogues."""
|
|
257
|
+
_write_pii_sources(taint_dir, buckets)
|
|
258
|
+
_write_dangerous_payload_fields(taint_dir, buckets)
|
|
259
|
+
_write_pii_sinks(taint_dir, buckets)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def write_crypto_and_payload_catalogues(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
263
|
+
"""Write crypto-operations and raw-code/SQL payload-endpoint catalogues."""
|
|
264
|
+
weak = [r for r in buckets.crypto_ops if r.get("detail", {}).get("weak")]
|
|
265
|
+
md = [
|
|
266
|
+
"# Crypto operations\n",
|
|
267
|
+
UNTRUSTED_CONTENT_NOTICE,
|
|
268
|
+
"_Algorithm references from v2 flow nodes._\n\n",
|
|
269
|
+
md_table(
|
|
270
|
+
["Algorithm", "Count"],
|
|
271
|
+
[[a, str(c)] for a, c in Counter(
|
|
272
|
+
(r.get("detail", {}).get("algorithm") or "?").upper()
|
|
273
|
+
for r in buckets.crypto_ops
|
|
274
|
+
).most_common(40)],
|
|
275
|
+
),
|
|
276
|
+
"\n## Weak algorithm uses\n",
|
|
277
|
+
md_table(
|
|
278
|
+
["Repo", "Algorithm", "File:line"],
|
|
279
|
+
[
|
|
280
|
+
[r["repo"], r.get("detail", {}).get("algorithm", ""), f"{r.get('file')}:{r.get('line')}"]
|
|
281
|
+
for r in weak[:200]
|
|
282
|
+
],
|
|
283
|
+
),
|
|
284
|
+
]
|
|
285
|
+
_write_jsonl(taint_dir / "crypto-operations.jsonl", buckets.crypto_ops)
|
|
286
|
+
(taint_dir / "crypto-operations.md").write_text("\n".join(md), encoding="utf-8")
|
|
287
|
+
|
|
288
|
+
md = _hierarchical_section(
|
|
289
|
+
"Raw code / SQL payload endpoints",
|
|
290
|
+
"_Inbound endpoints whose handler accepts a `sql`/`query`/… field and "
|
|
291
|
+
"reaches an execution sink in the same file. Use `trace.py` for cross-repo "
|
|
292
|
+
"producer analysis._\n",
|
|
293
|
+
[["Confidence", "Count"],
|
|
294
|
+
*[[str(k), str(v)] for k, v in Counter(r.get("confidence") for r in buckets.raw_payload).most_common()]],
|
|
295
|
+
[
|
|
296
|
+
[
|
|
297
|
+
r["repo"],
|
|
298
|
+
r.get("detail", {}).get("endpoint_path", ""),
|
|
299
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
300
|
+
r.get("detail", {}).get("sink_symbol", ""),
|
|
301
|
+
]
|
|
302
|
+
for r in buckets.raw_payload
|
|
303
|
+
],
|
|
304
|
+
["Repo", "Endpoint", "Field location", "Sink"],
|
|
305
|
+
max(0, len(buckets.raw_payload) - MAX_MD_ROWS),
|
|
306
|
+
taint_dir / "raw-code-payload-endpoints.jsonl",
|
|
307
|
+
)
|
|
308
|
+
_write_jsonl(taint_dir / "raw-code-payload-endpoints.jsonl", buckets.raw_payload)
|
|
309
|
+
(taint_dir / "raw-code-payload-endpoints.md").write_text("\n".join(md), encoding="utf-8")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def write_config_catalogues(taint_dir: Path, buckets: TaintCatalogueBuckets) -> None:
|
|
313
|
+
"""Write config data-store, security-key, and (optional) crypto-config catalogues."""
|
|
314
|
+
store_by_vendor = Counter(r.get("detail", {}).get("vendor", "?") for r in buckets.config_stores)
|
|
315
|
+
md = _hierarchical_section(
|
|
316
|
+
"Data stores referenced in configuration",
|
|
317
|
+
"_JDBC / MongoDB / Redis / S3 bucket references from YAML, "
|
|
318
|
+
"properties, Helm values, and `.env`._\n",
|
|
319
|
+
[["Vendor", "Count"], *[[k, str(v)] for k, v in store_by_vendor.most_common()]],
|
|
320
|
+
[
|
|
321
|
+
[
|
|
322
|
+
r["repo"],
|
|
323
|
+
r.get("detail", {}).get("vendor", ""),
|
|
324
|
+
str(r.get("detail", {}).get("url") or r.get("detail", {}).get("bucket", ""))[:80],
|
|
325
|
+
f"{r.get('file')}:{r.get('line')}",
|
|
326
|
+
]
|
|
327
|
+
for r in buckets.config_stores
|
|
328
|
+
],
|
|
329
|
+
["Repo", "Vendor", "Target", "File:line"],
|
|
330
|
+
max(0, len(buckets.config_stores) - MAX_MD_ROWS),
|
|
331
|
+
taint_dir / "config-data-stores.jsonl",
|
|
332
|
+
)
|
|
333
|
+
_write_jsonl(taint_dir / "config-data-stores.jsonl", buckets.config_stores)
|
|
334
|
+
(taint_dir / "config-data-stores.md").write_text("\n".join(md), encoding="utf-8")
|
|
335
|
+
|
|
336
|
+
md = _hierarchical_section(
|
|
337
|
+
"Security-sensitive configuration keys",
|
|
338
|
+
"_Same key set as v1 `config_security_flags`, as v2 flow nodes._\n",
|
|
339
|
+
[["Key", "Count"],
|
|
340
|
+
*[[k, str(v)] for k, v in Counter(
|
|
341
|
+
r.get("detail", {}).get("key", "?") for r in buckets.config_security
|
|
342
|
+
).most_common(30)]],
|
|
343
|
+
[
|
|
344
|
+
[r["repo"], r.get("detail", {}).get("key", ""), f"{r.get('file')}:{r.get('line')}"]
|
|
345
|
+
for r in buckets.config_security
|
|
346
|
+
],
|
|
347
|
+
["Repo", "Key", "File:line"],
|
|
348
|
+
max(0, len(buckets.config_security) - MAX_MD_ROWS),
|
|
349
|
+
taint_dir / "config-security.jsonl",
|
|
350
|
+
)
|
|
351
|
+
_write_jsonl(taint_dir / "config-security.jsonl", buckets.config_security)
|
|
352
|
+
(taint_dir / "config-security.md").write_text("\n".join(md), encoding="utf-8")
|
|
353
|
+
|
|
354
|
+
if not buckets.crypto_config:
|
|
355
|
+
return
|
|
356
|
+
md = [
|
|
357
|
+
"# Crypto settings from configuration\n",
|
|
358
|
+
UNTRUSTED_CONTENT_NOTICE,
|
|
359
|
+
"_Algorithm / cipher suite keys in config (config-driven crypto)._ \n\n",
|
|
360
|
+
md_table(
|
|
361
|
+
["Repo", "Subkind", "File:line"],
|
|
362
|
+
[
|
|
363
|
+
[r["repo"], r.get("detail", {}).get("subkind", ""), f"{r.get('file')}:{r.get('line')}"]
|
|
364
|
+
for r in buckets.crypto_config[:MAX_MD_ROWS]
|
|
365
|
+
],
|
|
366
|
+
),
|
|
367
|
+
]
|
|
368
|
+
_write_jsonl(taint_dir / "config-crypto.jsonl", buckets.crypto_config)
|
|
369
|
+
(taint_dir / "config-crypto.md").write_text("\n".join(md), encoding="utf-8")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Generate graphs/traces/INDEX.md from batch trace outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..renderers.markdown import md_table
|
|
10
|
+
from ..sanitize import UNTRUSTED_CONTENT_NOTICE
|
|
11
|
+
|
|
12
|
+
TRACE_HEADER_RX = re.compile(r"^# Flow trace:\s*(\S+)\s*$", re.MULTILINE)
|
|
13
|
+
PATH_FILTER_RX = re.compile(r"^_\s*Path filter:\s*`([^`]+)`", re.MULTILINE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_trace_file(md_path: Path) -> tuple[str, str]:
|
|
17
|
+
"""Extract (repo, path filter) from a trace report's header."""
|
|
18
|
+
try:
|
|
19
|
+
head = md_path.read_text(encoding="utf-8")[:500]
|
|
20
|
+
except OSError:
|
|
21
|
+
return "?", "?"
|
|
22
|
+
m = TRACE_HEADER_RX.search(head)
|
|
23
|
+
repo = m.group(1) if m else "?"
|
|
24
|
+
pf = PATH_FILTER_RX.search(head)
|
|
25
|
+
path = pf.group(1) if pf else "—"
|
|
26
|
+
return repo, path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_catalogue(cat_path: Path) -> set[tuple[str, str]]:
|
|
30
|
+
"""Load (repo, endpoint_path) pairs from a raw-code-payload endpoints jsonl."""
|
|
31
|
+
catalogue: set[tuple[str, str]] = set()
|
|
32
|
+
if not cat_path.is_file():
|
|
33
|
+
return catalogue
|
|
34
|
+
with cat_path.open(encoding="utf-8") as fh:
|
|
35
|
+
for line in fh:
|
|
36
|
+
if not line.strip():
|
|
37
|
+
continue
|
|
38
|
+
rec = json.loads(line)
|
|
39
|
+
repo = rec.get("repo", "")
|
|
40
|
+
path = (rec.get("detail") or {}).get("endpoint_path", "")
|
|
41
|
+
if repo and path:
|
|
42
|
+
catalogue.add((repo, path))
|
|
43
|
+
return catalogue
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _trace_index_rows(
|
|
47
|
+
traces_dir: Path, catalogue: set[tuple[str, str]]
|
|
48
|
+
) -> tuple[list[list[str]], set[tuple[str, str]]]:
|
|
49
|
+
"""Build index table rows and the set of traced (repo, path) pairs."""
|
|
50
|
+
rows: list[list[str]] = []
|
|
51
|
+
traced: set[tuple[str, str]] = set()
|
|
52
|
+
for md_path in sorted(traces_dir.glob("*.md")):
|
|
53
|
+
if md_path.name == "INDEX.md":
|
|
54
|
+
continue
|
|
55
|
+
repo, path = _parse_trace_file(md_path)
|
|
56
|
+
if path != "—":
|
|
57
|
+
traced.add((repo, path))
|
|
58
|
+
in_cat = "yes" if (repo, path) in catalogue else "—"
|
|
59
|
+
rows.append([repo, path, in_cat, f"[{md_path.name}](./{md_path.name})"])
|
|
60
|
+
return rows, traced
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_traces_index(metabase_root: Path) -> int:
|
|
64
|
+
"""Write graphs/traces/INDEX.md from trace reports and return the report count."""
|
|
65
|
+
traces_dir = metabase_root / "graphs" / "traces"
|
|
66
|
+
if not traces_dir.is_dir():
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
catalogue = _load_catalogue(metabase_root / "taint" / "raw-code-payload-endpoints.jsonl")
|
|
70
|
+
rows, traced = _trace_index_rows(traces_dir, catalogue)
|
|
71
|
+
|
|
72
|
+
md: list[str] = [
|
|
73
|
+
"# Raw-code-payload trace reports\n",
|
|
74
|
+
UNTRUSTED_CONTENT_NOTICE,
|
|
75
|
+
f"_{len(rows)} reports from `trace_batch.py` / `trace.py`._\n",
|
|
76
|
+
"\n## Reports\n",
|
|
77
|
+
md_table(
|
|
78
|
+
["Repo", "Endpoint", "In catalogue", "Report"],
|
|
79
|
+
rows[:500],
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
if len(rows) > 500:
|
|
83
|
+
md.append(f"\n_{len(rows) - 500} more trace files in this directory._\n")
|
|
84
|
+
|
|
85
|
+
if catalogue:
|
|
86
|
+
missing = sorted(catalogue - traced)
|
|
87
|
+
md.append(
|
|
88
|
+
f"\n**Catalogue coverage:** {len(traced & catalogue)} / "
|
|
89
|
+
f"{len(catalogue)} endpoints have traces.\n",
|
|
90
|
+
)
|
|
91
|
+
if missing:
|
|
92
|
+
md.append("\n### Missing traces (sample)\n")
|
|
93
|
+
md.append(
|
|
94
|
+
md_table(
|
|
95
|
+
["Repo", "Endpoint"],
|
|
96
|
+
[[r, p] for r, p in missing[:40]],
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
if len(missing) > 40:
|
|
100
|
+
md.append(f"\n_{len(missing) - 40} more — run `trace_batch.py --skip-existing`._\n")
|
|
101
|
+
|
|
102
|
+
(traces_dir / "INDEX.md").write_text("\n".join(md), encoding="utf-8")
|
|
103
|
+
return len(rows)
|