trailmark 0.1.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.
- trailmark/__init__.py +3 -0
- trailmark/analysis/__init__.py +5 -0
- trailmark/analysis/augment.py +384 -0
- trailmark/analysis/preanalysis.py +318 -0
- trailmark/cli.py +156 -0
- trailmark/diagram.py +521 -0
- trailmark/models/__init__.py +43 -0
- trailmark/models/annotations.py +88 -0
- trailmark/models/edges.py +37 -0
- trailmark/models/graph.py +67 -0
- trailmark/models/nodes.py +77 -0
- trailmark/parsers/__init__.py +1 -0
- trailmark/parsers/_common.py +260 -0
- trailmark/parsers/base.py +24 -0
- trailmark/parsers/c/__init__.py +5 -0
- trailmark/parsers/c/parser.py +424 -0
- trailmark/parsers/cairo/__init__.py +5 -0
- trailmark/parsers/cairo/parser.py +605 -0
- trailmark/parsers/circom/__init__.py +5 -0
- trailmark/parsers/circom/parser.py +322 -0
- trailmark/parsers/cpp/__init__.py +5 -0
- trailmark/parsers/cpp/parser.py +638 -0
- trailmark/parsers/csharp/__init__.py +5 -0
- trailmark/parsers/csharp/parser.py +574 -0
- trailmark/parsers/erlang/__init__.py +5 -0
- trailmark/parsers/erlang/parser.py +621 -0
- trailmark/parsers/go/__init__.py +5 -0
- trailmark/parsers/go/parser.py +447 -0
- trailmark/parsers/haskell/__init__.py +5 -0
- trailmark/parsers/haskell/parser.py +691 -0
- trailmark/parsers/java/__init__.py +5 -0
- trailmark/parsers/java/parser.py +553 -0
- trailmark/parsers/javascript/__init__.py +5 -0
- trailmark/parsers/javascript/parser.py +612 -0
- trailmark/parsers/masm/__init__.py +5 -0
- trailmark/parsers/masm/parser.py +361 -0
- trailmark/parsers/php/__init__.py +5 -0
- trailmark/parsers/php/parser.py +560 -0
- trailmark/parsers/python/__init__.py +5 -0
- trailmark/parsers/python/parser.py +434 -0
- trailmark/parsers/ruby/__init__.py +5 -0
- trailmark/parsers/ruby/parser.py +523 -0
- trailmark/parsers/rust/__init__.py +5 -0
- trailmark/parsers/rust/parser.py +473 -0
- trailmark/parsers/solidity/__init__.py +5 -0
- trailmark/parsers/solidity/parser.py +430 -0
- trailmark/parsers/typescript/__init__.py +5 -0
- trailmark/parsers/typescript/parser.py +905 -0
- trailmark/py.typed +0 -0
- trailmark/query/__init__.py +5 -0
- trailmark/query/api.py +286 -0
- trailmark/storage/__init__.py +5 -0
- trailmark/storage/graph_store.py +241 -0
- trailmark/tree_sitter_custom/__init__.py +0 -0
- trailmark/tree_sitter_custom/circom/LICENSE +21 -0
- trailmark/tree_sitter_custom/circom/__init__.py +56 -0
- trailmark/tree_sitter_custom/circom/binding.c +27 -0
- trailmark/tree_sitter_custom/circom/grammar.js +495 -0
- trailmark/tree_sitter_custom/circom/src/parser.c +12129 -0
- trailmark/tree_sitter_custom/circom/src/tree_sitter/parser.h +266 -0
- trailmark/tree_sitter_custom/masm/LICENSE +21 -0
- trailmark/tree_sitter_custom/masm/__init__.py +55 -0
- trailmark/tree_sitter_custom/masm/binding.c +27 -0
- trailmark/tree_sitter_custom/masm/grammar.js +444 -0
- trailmark/tree_sitter_custom/masm/src/parser.c +24046 -0
- trailmark/tree_sitter_custom/masm/src/tree_sitter/alloc.h +54 -0
- trailmark/tree_sitter_custom/masm/src/tree_sitter/array.h +291 -0
- trailmark/tree_sitter_custom/masm/src/tree_sitter/parser.h +286 -0
- trailmark-0.1.0.dist-info/METADATA +309 -0
- trailmark-0.1.0.dist-info/RECORD +74 -0
- trailmark-0.1.0.dist-info/WHEEL +4 -0
- trailmark-0.1.0.dist-info/entry_points.txt +2 -0
- trailmark-0.1.0.dist-info/licenses/LICENSE +200 -0
- trailmark-0.1.0.dist-info/licenses/NOTICE +12 -0
trailmark/__init__.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Augment code graphs with external findings from SARIF and weAudit files.
|
|
2
|
+
|
|
3
|
+
Parses SARIF 2.1.0 static analysis results and weAudit audit annotations,
|
|
4
|
+
matches findings to graph nodes by file path and line range overlap, and
|
|
5
|
+
stores them as annotations and named subgraphs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import unquote, urlparse
|
|
14
|
+
|
|
15
|
+
from trailmark.models.annotations import Annotation, AnnotationKind
|
|
16
|
+
from trailmark.models.graph import CodeGraph
|
|
17
|
+
from trailmark.storage.graph_store import GraphStore
|
|
18
|
+
|
|
19
|
+
_AUGMENT_SUBGRAPH_PREFIXES = ("sarif:", "weaudit:")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def augment_from_sarif(
|
|
23
|
+
store: GraphStore,
|
|
24
|
+
sarif_path: str,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Parse a SARIF file and augment the graph with findings.
|
|
27
|
+
|
|
28
|
+
Reads SARIF 2.1.0 JSON, maps results to graph nodes by file/line
|
|
29
|
+
overlap, creates annotations and subgraphs by severity and tool.
|
|
30
|
+
|
|
31
|
+
Returns a summary dict with match statistics.
|
|
32
|
+
"""
|
|
33
|
+
clear_augmented(store, "sarif")
|
|
34
|
+
with open(sarif_path) as f:
|
|
35
|
+
sarif = json.load(f)
|
|
36
|
+
|
|
37
|
+
graph = store._graph # noqa: SLF001
|
|
38
|
+
matched = 0
|
|
39
|
+
unmatched = 0
|
|
40
|
+
subgraph_sets: dict[str, set[str]] = {}
|
|
41
|
+
|
|
42
|
+
for run in sarif.get("runs", []):
|
|
43
|
+
tool_name = _sarif_tool_name(run)
|
|
44
|
+
for result in run.get("results", []):
|
|
45
|
+
node_ids = _process_sarif_result(
|
|
46
|
+
result,
|
|
47
|
+
tool_name,
|
|
48
|
+
graph,
|
|
49
|
+
)
|
|
50
|
+
if node_ids:
|
|
51
|
+
matched += 1
|
|
52
|
+
_add_to_subgraphs(
|
|
53
|
+
subgraph_sets,
|
|
54
|
+
node_ids,
|
|
55
|
+
result,
|
|
56
|
+
tool_name,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
unmatched += 1
|
|
60
|
+
|
|
61
|
+
for name, ids in subgraph_sets.items():
|
|
62
|
+
store.add_subgraph(name, ids)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"matched_findings": matched,
|
|
66
|
+
"unmatched_findings": unmatched,
|
|
67
|
+
"subgraphs_created": sorted(subgraph_sets.keys()),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def augment_from_weaudit(
|
|
72
|
+
store: GraphStore,
|
|
73
|
+
weaudit_path: str,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Parse a weAudit file and augment the graph with findings.
|
|
76
|
+
|
|
77
|
+
Reads weAudit JSON, maps entries to graph nodes by file/line
|
|
78
|
+
overlap (converting 0-indexed to 1-indexed lines), creates
|
|
79
|
+
annotations and subgraphs by severity and entry type.
|
|
80
|
+
|
|
81
|
+
Returns a summary dict with match statistics.
|
|
82
|
+
"""
|
|
83
|
+
clear_augmented(store, "weaudit")
|
|
84
|
+
with open(weaudit_path) as f:
|
|
85
|
+
data = json.load(f)
|
|
86
|
+
|
|
87
|
+
graph = store._graph # noqa: SLF001
|
|
88
|
+
matched = 0
|
|
89
|
+
unmatched = 0
|
|
90
|
+
subgraph_sets: dict[str, set[str]] = {}
|
|
91
|
+
|
|
92
|
+
entries = data.get("treeEntries", [])
|
|
93
|
+
entries += data.get("resolvedEntries", [])
|
|
94
|
+
|
|
95
|
+
for entry in entries:
|
|
96
|
+
node_ids = _process_weaudit_entry(entry, data, graph)
|
|
97
|
+
if node_ids:
|
|
98
|
+
matched += 1
|
|
99
|
+
_add_weaudit_to_subgraphs(subgraph_sets, node_ids, entry)
|
|
100
|
+
else:
|
|
101
|
+
unmatched += 1
|
|
102
|
+
|
|
103
|
+
for name, ids in subgraph_sets.items():
|
|
104
|
+
store.add_subgraph(name, ids)
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"matched_findings": matched,
|
|
108
|
+
"unmatched_findings": unmatched,
|
|
109
|
+
"subgraphs_created": sorted(subgraph_sets.keys()),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def clear_augmented(store: GraphStore, source_prefix: str) -> None:
|
|
114
|
+
"""Remove annotations and subgraphs from a prior augmentation run.
|
|
115
|
+
|
|
116
|
+
Filters annotations whose source starts with the given prefix
|
|
117
|
+
and deletes subgraphs whose name starts with that prefix followed
|
|
118
|
+
by a colon.
|
|
119
|
+
"""
|
|
120
|
+
graph = store._graph # noqa: SLF001
|
|
121
|
+
for node_id in list(graph.annotations):
|
|
122
|
+
graph.annotations[node_id] = [
|
|
123
|
+
a for a in graph.annotations[node_id] if not a.source.startswith(source_prefix)
|
|
124
|
+
]
|
|
125
|
+
if not graph.annotations[node_id]:
|
|
126
|
+
del graph.annotations[node_id]
|
|
127
|
+
|
|
128
|
+
sg_prefix = f"{source_prefix}:"
|
|
129
|
+
for name in list(graph.subgraphs):
|
|
130
|
+
if name.startswith(sg_prefix):
|
|
131
|
+
del graph.subgraphs[name]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Path matching ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_path(uri: str, root_path: str) -> str | None:
|
|
138
|
+
"""Normalize a URI or file path to be relative to root_path.
|
|
139
|
+
|
|
140
|
+
Handles file:// URIs, absolute paths, and relative paths.
|
|
141
|
+
Returns None if the path cannot be resolved under root_path.
|
|
142
|
+
"""
|
|
143
|
+
path = uri
|
|
144
|
+
parsed = urlparse(uri)
|
|
145
|
+
if parsed.scheme == "file":
|
|
146
|
+
path = unquote(parsed.path)
|
|
147
|
+
elif parsed.scheme and parsed.scheme != "":
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if os.path.isabs(path):
|
|
151
|
+
try:
|
|
152
|
+
return os.path.relpath(path, root_path)
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
return path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _find_overlapping_nodes(
|
|
160
|
+
graph: CodeGraph,
|
|
161
|
+
rel_path: str,
|
|
162
|
+
start_line: int,
|
|
163
|
+
end_line: int,
|
|
164
|
+
) -> list[str]:
|
|
165
|
+
"""Find graph nodes whose source location overlaps the given range.
|
|
166
|
+
|
|
167
|
+
Returns node IDs sorted by span size (tightest match first).
|
|
168
|
+
"""
|
|
169
|
+
root = graph.root_path
|
|
170
|
+
matches: list[tuple[int, str]] = []
|
|
171
|
+
|
|
172
|
+
for node_id, node in graph.nodes.items():
|
|
173
|
+
loc = node.location
|
|
174
|
+
node_rel = _normalize_path(loc.file_path, root)
|
|
175
|
+
if node_rel is None or node_rel != rel_path:
|
|
176
|
+
continue
|
|
177
|
+
if loc.start_line <= end_line and loc.end_line >= start_line:
|
|
178
|
+
span = loc.end_line - loc.start_line
|
|
179
|
+
matches.append((span, node_id))
|
|
180
|
+
|
|
181
|
+
matches.sort(key=lambda m: m[0])
|
|
182
|
+
return [node_id for _, node_id in matches]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── SARIF processing ────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _sarif_tool_name(run: dict[str, Any]) -> str:
|
|
189
|
+
"""Extract the tool name from a SARIF run object."""
|
|
190
|
+
tool = run.get("tool", {})
|
|
191
|
+
driver = tool.get("driver", {})
|
|
192
|
+
return driver.get("name", "unknown")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _sarif_level(result: dict[str, Any]) -> str:
|
|
196
|
+
"""Extract severity level from a SARIF result."""
|
|
197
|
+
return result.get("level", "warning")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _format_sarif_description(
|
|
201
|
+
result: dict[str, Any],
|
|
202
|
+
tool_name: str,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Build a compact single-line description from a SARIF result."""
|
|
205
|
+
level = _sarif_level(result).upper()
|
|
206
|
+
rule_id = result.get("ruleId", "unknown")
|
|
207
|
+
message = result.get("message", {}).get("text", "")
|
|
208
|
+
return f"[{level}] {rule_id}: {message} ({tool_name})"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _process_sarif_result(
|
|
212
|
+
result: dict[str, Any],
|
|
213
|
+
tool_name: str,
|
|
214
|
+
graph: CodeGraph,
|
|
215
|
+
) -> set[str]:
|
|
216
|
+
"""Map a single SARIF result to graph nodes and annotate them.
|
|
217
|
+
|
|
218
|
+
Returns the set of node IDs that were annotated.
|
|
219
|
+
"""
|
|
220
|
+
description = _format_sarif_description(result, tool_name)
|
|
221
|
+
source = f"sarif:{tool_name}"
|
|
222
|
+
all_node_ids: set[str] = set()
|
|
223
|
+
locations = result.get("locations", [])
|
|
224
|
+
|
|
225
|
+
for loc in locations:
|
|
226
|
+
phys = loc.get("physicalLocation", {})
|
|
227
|
+
node_ids = _nodes_from_physical_location(phys, graph)
|
|
228
|
+
all_node_ids.update(node_ids)
|
|
229
|
+
|
|
230
|
+
_annotate_nodes(graph, all_node_ids, description, source)
|
|
231
|
+
return all_node_ids
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _nodes_from_physical_location(
|
|
235
|
+
phys: dict[str, Any],
|
|
236
|
+
graph: CodeGraph,
|
|
237
|
+
) -> list[str]:
|
|
238
|
+
"""Resolve a SARIF physicalLocation to overlapping graph nodes."""
|
|
239
|
+
artifact = phys.get("artifactLocation", {})
|
|
240
|
+
uri = artifact.get("uri", "")
|
|
241
|
+
if not uri:
|
|
242
|
+
return []
|
|
243
|
+
|
|
244
|
+
region = phys.get("region", {})
|
|
245
|
+
start_line = region.get("startLine", 1)
|
|
246
|
+
end_line = region.get("endLine", start_line)
|
|
247
|
+
|
|
248
|
+
rel_path = _normalize_path(uri, graph.root_path)
|
|
249
|
+
if rel_path is None:
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
return _find_overlapping_nodes(graph, rel_path, start_line, end_line)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_to_subgraphs(
|
|
256
|
+
subgraph_sets: dict[str, set[str]],
|
|
257
|
+
node_ids: set[str],
|
|
258
|
+
result: dict[str, Any],
|
|
259
|
+
tool_name: str,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Add node IDs to SARIF-derived subgraphs by level and tool."""
|
|
262
|
+
level = _sarif_level(result)
|
|
263
|
+
level_key = f"sarif:{level}"
|
|
264
|
+
subgraph_sets.setdefault(level_key, set()).update(node_ids)
|
|
265
|
+
|
|
266
|
+
tool_key = f"sarif:{tool_name}"
|
|
267
|
+
subgraph_sets.setdefault(tool_key, set()).update(node_ids)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ── weAudit processing ──────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _weaudit_author(data: dict[str, Any]) -> str:
|
|
274
|
+
"""Extract a default author from the weAudit file metadata."""
|
|
275
|
+
remote = data.get("clientRemote", "")
|
|
276
|
+
if "/" in remote:
|
|
277
|
+
return remote.rsplit("/", 1)[-1]
|
|
278
|
+
return "unknown"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _format_weaudit_description(
|
|
282
|
+
entry: dict[str, Any],
|
|
283
|
+
author: str,
|
|
284
|
+
) -> str:
|
|
285
|
+
"""Build a compact single-line description from a weAudit entry."""
|
|
286
|
+
details = entry.get("details", {})
|
|
287
|
+
severity = details.get("severity", "").upper() or "UNSET"
|
|
288
|
+
label = entry.get("label", "untitled")
|
|
289
|
+
finding_type = details.get("type", "")
|
|
290
|
+
desc = details.get("description", "")
|
|
291
|
+
|
|
292
|
+
parts = [f"[{severity}] {label}"]
|
|
293
|
+
if finding_type:
|
|
294
|
+
parts.append(f"({finding_type})")
|
|
295
|
+
if desc:
|
|
296
|
+
first_line = desc.split("\n", 1)[0]
|
|
297
|
+
parts.append(f"- {first_line}")
|
|
298
|
+
parts.append(f"[{author}]")
|
|
299
|
+
return " ".join(parts)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _process_weaudit_entry(
|
|
303
|
+
entry: dict[str, Any],
|
|
304
|
+
data: dict[str, Any],
|
|
305
|
+
graph: CodeGraph,
|
|
306
|
+
) -> set[str]:
|
|
307
|
+
"""Map a single weAudit entry to graph nodes and annotate them.
|
|
308
|
+
|
|
309
|
+
Returns the set of node IDs that were annotated.
|
|
310
|
+
"""
|
|
311
|
+
author = entry.get("author", _weaudit_author(data))
|
|
312
|
+
description = _format_weaudit_description(entry, author)
|
|
313
|
+
entry_type = entry.get("entryType", 0)
|
|
314
|
+
kind = AnnotationKind.AUDIT_NOTE if entry_type == 1 else AnnotationKind.FINDING
|
|
315
|
+
source = f"weaudit:{author}"
|
|
316
|
+
all_node_ids: set[str] = set()
|
|
317
|
+
|
|
318
|
+
for loc in entry.get("locations", []):
|
|
319
|
+
node_ids = _nodes_from_weaudit_location(loc, graph)
|
|
320
|
+
all_node_ids.update(node_ids)
|
|
321
|
+
|
|
322
|
+
_annotate_nodes(
|
|
323
|
+
graph,
|
|
324
|
+
all_node_ids,
|
|
325
|
+
description,
|
|
326
|
+
source,
|
|
327
|
+
kind=kind,
|
|
328
|
+
)
|
|
329
|
+
return all_node_ids
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _nodes_from_weaudit_location(
|
|
333
|
+
loc: dict[str, Any],
|
|
334
|
+
graph: CodeGraph,
|
|
335
|
+
) -> list[str]:
|
|
336
|
+
"""Resolve a weAudit location to overlapping graph nodes.
|
|
337
|
+
|
|
338
|
+
Converts 0-indexed weAudit lines to 1-indexed Trailmark lines.
|
|
339
|
+
"""
|
|
340
|
+
path = loc.get("path", "")
|
|
341
|
+
if not path:
|
|
342
|
+
return []
|
|
343
|
+
|
|
344
|
+
start_line = loc.get("startLine", 0) + 1
|
|
345
|
+
end_line = loc.get("endLine", 0) + 1
|
|
346
|
+
|
|
347
|
+
rel_path = _normalize_path(path, graph.root_path)
|
|
348
|
+
if rel_path is None:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
return _find_overlapping_nodes(graph, rel_path, start_line, end_line)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _add_weaudit_to_subgraphs(
|
|
355
|
+
subgraph_sets: dict[str, set[str]],
|
|
356
|
+
node_ids: set[str],
|
|
357
|
+
entry: dict[str, Any],
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Add node IDs to weAudit-derived subgraphs."""
|
|
360
|
+
entry_type = entry.get("entryType", 0)
|
|
361
|
+
type_key = "weaudit:findings" if entry_type == 0 else "weaudit:notes"
|
|
362
|
+
subgraph_sets.setdefault(type_key, set()).update(node_ids)
|
|
363
|
+
|
|
364
|
+
details = entry.get("details", {})
|
|
365
|
+
severity = details.get("severity", "").lower()
|
|
366
|
+
if severity:
|
|
367
|
+
sev_key = f"weaudit:{severity}"
|
|
368
|
+
subgraph_sets.setdefault(sev_key, set()).update(node_ids)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── Shared helpers ───────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _annotate_nodes(
|
|
375
|
+
graph: CodeGraph,
|
|
376
|
+
node_ids: set[str],
|
|
377
|
+
description: str,
|
|
378
|
+
source: str,
|
|
379
|
+
kind: AnnotationKind = AnnotationKind.FINDING,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Add an annotation to each node in the set."""
|
|
382
|
+
ann = Annotation(kind=kind, description=description, source=source)
|
|
383
|
+
for node_id in node_ids:
|
|
384
|
+
graph.add_annotation(node_id, ann)
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Pre-analysis passes that enrich code graphs before downstream skills.
|
|
2
|
+
|
|
3
|
+
Runs four passes over the graph:
|
|
4
|
+
1. Blast radius estimation — how many nodes are affected per function
|
|
5
|
+
2. Entry point enumeration — systematic entrypoint + reachability mapping
|
|
6
|
+
3. Privilege boundary crossing — where trust levels change across calls
|
|
7
|
+
4. Taint propagation — untrusted data flow through the call graph
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import rustworkx as rx
|
|
15
|
+
|
|
16
|
+
from trailmark.models.annotations import (
|
|
17
|
+
Annotation,
|
|
18
|
+
AnnotationKind,
|
|
19
|
+
TrustLevel,
|
|
20
|
+
)
|
|
21
|
+
from trailmark.models.edges import EdgeKind
|
|
22
|
+
from trailmark.models.graph import CodeGraph
|
|
23
|
+
from trailmark.storage.graph_store import GraphStore
|
|
24
|
+
|
|
25
|
+
_BLAST_RADIUS_THRESHOLD = 10
|
|
26
|
+
_PREANALYSIS_SOURCE = "preanalysis"
|
|
27
|
+
_PREANALYSIS_SUBGRAPHS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"high_blast_radius",
|
|
30
|
+
"entrypoints",
|
|
31
|
+
"entrypoint_reachable",
|
|
32
|
+
"privilege_boundary",
|
|
33
|
+
"tainted",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _clear_prior_preanalysis(store: GraphStore) -> None:
|
|
39
|
+
"""Remove annotations and subgraphs from a previous preanalysis run."""
|
|
40
|
+
graph = store._graph # noqa: SLF001
|
|
41
|
+
for node_id in list(graph.annotations):
|
|
42
|
+
graph.annotations[node_id] = [
|
|
43
|
+
a for a in graph.annotations[node_id] if a.source != _PREANALYSIS_SOURCE
|
|
44
|
+
]
|
|
45
|
+
if not graph.annotations[node_id]:
|
|
46
|
+
del graph.annotations[node_id]
|
|
47
|
+
for name in list(graph.subgraphs):
|
|
48
|
+
if name in _PREANALYSIS_SUBGRAPHS or name.startswith("entrypoints:"):
|
|
49
|
+
del graph.subgraphs[name]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_preanalysis(store: GraphStore) -> dict[str, Any]:
|
|
53
|
+
"""Run all pre-analysis passes and return a summary.
|
|
54
|
+
|
|
55
|
+
Mutates the graph by adding annotations and subgraphs.
|
|
56
|
+
Returns a dict summarising what each pass found.
|
|
57
|
+
|
|
58
|
+
Safe to call multiple times: clears prior preanalysis
|
|
59
|
+
annotations before recomputing.
|
|
60
|
+
"""
|
|
61
|
+
_clear_prior_preanalysis(store)
|
|
62
|
+
blast = _compute_blast_radius(store)
|
|
63
|
+
entry = _enumerate_entrypoints(store)
|
|
64
|
+
priv = _detect_privilege_boundaries(store)
|
|
65
|
+
taint = _propagate_taint(store)
|
|
66
|
+
return {
|
|
67
|
+
"blast_radius": blast,
|
|
68
|
+
"entrypoints": entry,
|
|
69
|
+
"privilege_boundaries": priv,
|
|
70
|
+
"taint_propagation": taint,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _compute_blast_radius(store: GraphStore) -> dict[str, Any]:
|
|
75
|
+
"""Annotate each node with its downstream blast radius.
|
|
76
|
+
|
|
77
|
+
Blast radius = number of nodes reachable via call edges.
|
|
78
|
+
Nodes above the threshold are collected in a subgraph.
|
|
79
|
+
"""
|
|
80
|
+
graph = store._graph # noqa: SLF001
|
|
81
|
+
digraph = store._digraph # noqa: SLF001
|
|
82
|
+
id_to_idx = store._id_to_idx # noqa: SLF001
|
|
83
|
+
idx_to_id = store._idx_to_id # noqa: SLF001
|
|
84
|
+
|
|
85
|
+
high_blast: set[str] = set()
|
|
86
|
+
max_radius = 0
|
|
87
|
+
annotated = 0
|
|
88
|
+
|
|
89
|
+
for node_id in graph.nodes:
|
|
90
|
+
idx = id_to_idx.get(node_id)
|
|
91
|
+
if idx is None:
|
|
92
|
+
continue
|
|
93
|
+
descendants = rx.descendants(digraph, idx)
|
|
94
|
+
downstream = len(descendants)
|
|
95
|
+
ancestors = rx.ancestors(digraph, idx)
|
|
96
|
+
upstream = len(ancestors)
|
|
97
|
+
|
|
98
|
+
critical = _top_critical_descendants(
|
|
99
|
+
descendants,
|
|
100
|
+
idx_to_id,
|
|
101
|
+
graph,
|
|
102
|
+
)
|
|
103
|
+
desc = f"{downstream} downstream, {upstream} upstream"
|
|
104
|
+
if critical:
|
|
105
|
+
desc += f"; critical: {', '.join(critical)}"
|
|
106
|
+
|
|
107
|
+
ann = Annotation(
|
|
108
|
+
kind=AnnotationKind.BLAST_RADIUS,
|
|
109
|
+
description=desc,
|
|
110
|
+
source="preanalysis",
|
|
111
|
+
)
|
|
112
|
+
graph.add_annotation(node_id, ann)
|
|
113
|
+
annotated += 1
|
|
114
|
+
|
|
115
|
+
if downstream >= _BLAST_RADIUS_THRESHOLD:
|
|
116
|
+
high_blast.add(node_id)
|
|
117
|
+
max_radius = max(max_radius, downstream)
|
|
118
|
+
|
|
119
|
+
store.add_subgraph("high_blast_radius", high_blast)
|
|
120
|
+
return {
|
|
121
|
+
"annotated_nodes": annotated,
|
|
122
|
+
"high_blast_count": len(high_blast),
|
|
123
|
+
"max_radius": max_radius,
|
|
124
|
+
"threshold": _BLAST_RADIUS_THRESHOLD,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _top_critical_descendants(
|
|
129
|
+
descendants: set[int],
|
|
130
|
+
idx_to_id: dict[int, str],
|
|
131
|
+
graph: CodeGraph,
|
|
132
|
+
limit: int = 5,
|
|
133
|
+
) -> list[str]:
|
|
134
|
+
"""Pick the highest-complexity descendants as critical."""
|
|
135
|
+
scored: list[tuple[int, str]] = []
|
|
136
|
+
for d_idx in descendants:
|
|
137
|
+
d_id = idx_to_id.get(d_idx)
|
|
138
|
+
if d_id is None:
|
|
139
|
+
continue
|
|
140
|
+
node = graph.nodes.get(d_id)
|
|
141
|
+
if node is None:
|
|
142
|
+
continue
|
|
143
|
+
cc = node.cyclomatic_complexity or 0
|
|
144
|
+
if cc > 0:
|
|
145
|
+
scored.append((cc, d_id))
|
|
146
|
+
scored.sort(reverse=True)
|
|
147
|
+
return [name for _, name in scored[:limit]]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _enumerate_entrypoints(store: GraphStore) -> dict[str, Any]:
|
|
151
|
+
"""Build subgraphs for entrypoints and their reachable nodes."""
|
|
152
|
+
graph = store._graph # noqa: SLF001
|
|
153
|
+
|
|
154
|
+
ep_ids: set[str] = set()
|
|
155
|
+
reachable_all: set[str] = set()
|
|
156
|
+
by_trust: dict[str, set[str]] = {}
|
|
157
|
+
|
|
158
|
+
for ep_id, tag in graph.entrypoints.items():
|
|
159
|
+
ep_ids.add(ep_id)
|
|
160
|
+
trust_key = tag.trust_level.value
|
|
161
|
+
by_trust.setdefault(trust_key, set()).add(ep_id)
|
|
162
|
+
|
|
163
|
+
reachable = store.reachable_from(ep_id)
|
|
164
|
+
reachable_all.update(reachable)
|
|
165
|
+
reachable_all.add(ep_id)
|
|
166
|
+
|
|
167
|
+
store.add_subgraph("entrypoints", ep_ids)
|
|
168
|
+
store.add_subgraph("entrypoint_reachable", reachable_all)
|
|
169
|
+
for trust_key, ids in by_trust.items():
|
|
170
|
+
store.add_subgraph(f"entrypoints:{trust_key}", ids)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"total_entrypoints": len(ep_ids),
|
|
174
|
+
"reachable_nodes": len(reachable_all),
|
|
175
|
+
"by_trust_level": {k: len(v) for k, v in by_trust.items()},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _detect_privilege_boundaries(
|
|
180
|
+
store: GraphStore,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
"""Find nodes where trust levels change across call edges.
|
|
183
|
+
|
|
184
|
+
A privilege boundary exists where a node reachable from an
|
|
185
|
+
untrusted entrypoint calls into a node reachable only from
|
|
186
|
+
trusted entrypoints (or not tagged at all).
|
|
187
|
+
"""
|
|
188
|
+
graph = store._graph # noqa: SLF001
|
|
189
|
+
|
|
190
|
+
untrusted_reachable = _reachable_from_trust(
|
|
191
|
+
store,
|
|
192
|
+
TrustLevel.UNTRUSTED_EXTERNAL,
|
|
193
|
+
)
|
|
194
|
+
semi_trusted_reachable = _reachable_from_trust(
|
|
195
|
+
store,
|
|
196
|
+
TrustLevel.SEMI_TRUSTED_EXTERNAL,
|
|
197
|
+
)
|
|
198
|
+
trusted_reachable = _reachable_from_trust(
|
|
199
|
+
store,
|
|
200
|
+
TrustLevel.TRUSTED_INTERNAL,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
boundary_nodes: set[str] = set()
|
|
204
|
+
|
|
205
|
+
for edge in graph.edges:
|
|
206
|
+
if edge.kind != EdgeKind.CALLS:
|
|
207
|
+
continue
|
|
208
|
+
src, tgt = edge.source_id, edge.target_id
|
|
209
|
+
src_trust = _node_trust_set(
|
|
210
|
+
src,
|
|
211
|
+
untrusted_reachable,
|
|
212
|
+
semi_trusted_reachable,
|
|
213
|
+
trusted_reachable,
|
|
214
|
+
)
|
|
215
|
+
tgt_trust = _node_trust_set(
|
|
216
|
+
tgt,
|
|
217
|
+
untrusted_reachable,
|
|
218
|
+
semi_trusted_reachable,
|
|
219
|
+
trusted_reachable,
|
|
220
|
+
)
|
|
221
|
+
if src_trust != tgt_trust and src_trust and tgt_trust:
|
|
222
|
+
boundary_nodes.add(src)
|
|
223
|
+
boundary_nodes.add(tgt)
|
|
224
|
+
for node_id in (src, tgt):
|
|
225
|
+
ann = Annotation(
|
|
226
|
+
kind=AnnotationKind.PRIVILEGE_BOUNDARY,
|
|
227
|
+
description=(
|
|
228
|
+
f"trust transition across call: "
|
|
229
|
+
f"{_trust_label(src_trust)}"
|
|
230
|
+
f" -> {_trust_label(tgt_trust)}"
|
|
231
|
+
),
|
|
232
|
+
source="preanalysis",
|
|
233
|
+
)
|
|
234
|
+
graph.add_annotation(node_id, ann)
|
|
235
|
+
|
|
236
|
+
store.add_subgraph("privilege_boundary", boundary_nodes)
|
|
237
|
+
return {"boundary_nodes": len(boundary_nodes)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _reachable_from_trust(
|
|
241
|
+
store: GraphStore,
|
|
242
|
+
trust: TrustLevel,
|
|
243
|
+
) -> set[str]:
|
|
244
|
+
"""Return all nodes reachable from entrypoints at a trust level."""
|
|
245
|
+
graph = store._graph # noqa: SLF001
|
|
246
|
+
result: set[str] = set()
|
|
247
|
+
for ep_id, tag in graph.entrypoints.items():
|
|
248
|
+
if tag.trust_level == trust:
|
|
249
|
+
result.add(ep_id)
|
|
250
|
+
result.update(store.reachable_from(ep_id))
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _node_trust_set(
|
|
255
|
+
node_id: str,
|
|
256
|
+
untrusted: set[str],
|
|
257
|
+
semi_trusted: set[str],
|
|
258
|
+
trusted: set[str],
|
|
259
|
+
) -> frozenset[str]:
|
|
260
|
+
"""Return the set of trust levels that can reach a node."""
|
|
261
|
+
levels: set[str] = set()
|
|
262
|
+
if node_id in untrusted:
|
|
263
|
+
levels.add("untrusted_external")
|
|
264
|
+
if node_id in semi_trusted:
|
|
265
|
+
levels.add("semi_trusted_external")
|
|
266
|
+
if node_id in trusted:
|
|
267
|
+
levels.add("trusted_internal")
|
|
268
|
+
return frozenset(levels)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _trust_label(levels: frozenset[str]) -> str:
|
|
272
|
+
if not levels:
|
|
273
|
+
return "none"
|
|
274
|
+
return "+".join(sorted(levels))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _propagate_taint(store: GraphStore) -> dict[str, Any]:
|
|
278
|
+
"""Propagate taint from untrusted entrypoints through calls.
|
|
279
|
+
|
|
280
|
+
Marks all reachable nodes as tainted and annotates each with
|
|
281
|
+
the entrypoint source that taints it.
|
|
282
|
+
"""
|
|
283
|
+
graph = store._graph # noqa: SLF001
|
|
284
|
+
|
|
285
|
+
tainted_all: set[str] = set()
|
|
286
|
+
taint_sources: dict[str, list[str]] = {}
|
|
287
|
+
|
|
288
|
+
for ep_id, tag in graph.entrypoints.items():
|
|
289
|
+
if tag.trust_level == TrustLevel.TRUSTED_INTERNAL:
|
|
290
|
+
continue
|
|
291
|
+
reachable = store.reachable_from(ep_id)
|
|
292
|
+
reachable.add(ep_id)
|
|
293
|
+
tainted_all.update(reachable)
|
|
294
|
+
|
|
295
|
+
for node_id in reachable:
|
|
296
|
+
taint_sources.setdefault(node_id, []).append(ep_id)
|
|
297
|
+
|
|
298
|
+
for node_id, sources in taint_sources.items():
|
|
299
|
+
unique = sorted(set(sources))
|
|
300
|
+
desc = f"tainted via: {', '.join(unique)}"
|
|
301
|
+
ann = Annotation(
|
|
302
|
+
kind=AnnotationKind.TAINT_PROPAGATION,
|
|
303
|
+
description=desc,
|
|
304
|
+
source="preanalysis",
|
|
305
|
+
)
|
|
306
|
+
graph.add_annotation(node_id, ann)
|
|
307
|
+
|
|
308
|
+
store.add_subgraph("tainted", tainted_all)
|
|
309
|
+
return {
|
|
310
|
+
"tainted_nodes": len(tainted_all),
|
|
311
|
+
"taint_sources": len(
|
|
312
|
+
[
|
|
313
|
+
ep_id
|
|
314
|
+
for ep_id, tag in graph.entrypoints.items()
|
|
315
|
+
if tag.trust_level != TrustLevel.TRUSTED_INTERNAL
|
|
316
|
+
],
|
|
317
|
+
),
|
|
318
|
+
}
|