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.
Files changed (74) hide show
  1. trailmark/__init__.py +3 -0
  2. trailmark/analysis/__init__.py +5 -0
  3. trailmark/analysis/augment.py +384 -0
  4. trailmark/analysis/preanalysis.py +318 -0
  5. trailmark/cli.py +156 -0
  6. trailmark/diagram.py +521 -0
  7. trailmark/models/__init__.py +43 -0
  8. trailmark/models/annotations.py +88 -0
  9. trailmark/models/edges.py +37 -0
  10. trailmark/models/graph.py +67 -0
  11. trailmark/models/nodes.py +77 -0
  12. trailmark/parsers/__init__.py +1 -0
  13. trailmark/parsers/_common.py +260 -0
  14. trailmark/parsers/base.py +24 -0
  15. trailmark/parsers/c/__init__.py +5 -0
  16. trailmark/parsers/c/parser.py +424 -0
  17. trailmark/parsers/cairo/__init__.py +5 -0
  18. trailmark/parsers/cairo/parser.py +605 -0
  19. trailmark/parsers/circom/__init__.py +5 -0
  20. trailmark/parsers/circom/parser.py +322 -0
  21. trailmark/parsers/cpp/__init__.py +5 -0
  22. trailmark/parsers/cpp/parser.py +638 -0
  23. trailmark/parsers/csharp/__init__.py +5 -0
  24. trailmark/parsers/csharp/parser.py +574 -0
  25. trailmark/parsers/erlang/__init__.py +5 -0
  26. trailmark/parsers/erlang/parser.py +621 -0
  27. trailmark/parsers/go/__init__.py +5 -0
  28. trailmark/parsers/go/parser.py +447 -0
  29. trailmark/parsers/haskell/__init__.py +5 -0
  30. trailmark/parsers/haskell/parser.py +691 -0
  31. trailmark/parsers/java/__init__.py +5 -0
  32. trailmark/parsers/java/parser.py +553 -0
  33. trailmark/parsers/javascript/__init__.py +5 -0
  34. trailmark/parsers/javascript/parser.py +612 -0
  35. trailmark/parsers/masm/__init__.py +5 -0
  36. trailmark/parsers/masm/parser.py +361 -0
  37. trailmark/parsers/php/__init__.py +5 -0
  38. trailmark/parsers/php/parser.py +560 -0
  39. trailmark/parsers/python/__init__.py +5 -0
  40. trailmark/parsers/python/parser.py +434 -0
  41. trailmark/parsers/ruby/__init__.py +5 -0
  42. trailmark/parsers/ruby/parser.py +523 -0
  43. trailmark/parsers/rust/__init__.py +5 -0
  44. trailmark/parsers/rust/parser.py +473 -0
  45. trailmark/parsers/solidity/__init__.py +5 -0
  46. trailmark/parsers/solidity/parser.py +430 -0
  47. trailmark/parsers/typescript/__init__.py +5 -0
  48. trailmark/parsers/typescript/parser.py +905 -0
  49. trailmark/py.typed +0 -0
  50. trailmark/query/__init__.py +5 -0
  51. trailmark/query/api.py +286 -0
  52. trailmark/storage/__init__.py +5 -0
  53. trailmark/storage/graph_store.py +241 -0
  54. trailmark/tree_sitter_custom/__init__.py +0 -0
  55. trailmark/tree_sitter_custom/circom/LICENSE +21 -0
  56. trailmark/tree_sitter_custom/circom/__init__.py +56 -0
  57. trailmark/tree_sitter_custom/circom/binding.c +27 -0
  58. trailmark/tree_sitter_custom/circom/grammar.js +495 -0
  59. trailmark/tree_sitter_custom/circom/src/parser.c +12129 -0
  60. trailmark/tree_sitter_custom/circom/src/tree_sitter/parser.h +266 -0
  61. trailmark/tree_sitter_custom/masm/LICENSE +21 -0
  62. trailmark/tree_sitter_custom/masm/__init__.py +55 -0
  63. trailmark/tree_sitter_custom/masm/binding.c +27 -0
  64. trailmark/tree_sitter_custom/masm/grammar.js +444 -0
  65. trailmark/tree_sitter_custom/masm/src/parser.c +24046 -0
  66. trailmark/tree_sitter_custom/masm/src/tree_sitter/alloc.h +54 -0
  67. trailmark/tree_sitter_custom/masm/src/tree_sitter/array.h +291 -0
  68. trailmark/tree_sitter_custom/masm/src/tree_sitter/parser.h +286 -0
  69. trailmark-0.1.0.dist-info/METADATA +309 -0
  70. trailmark-0.1.0.dist-info/RECORD +74 -0
  71. trailmark-0.1.0.dist-info/WHEEL +4 -0
  72. trailmark-0.1.0.dist-info/entry_points.txt +2 -0
  73. trailmark-0.1.0.dist-info/licenses/LICENSE +200 -0
  74. trailmark-0.1.0.dist-info/licenses/NOTICE +12 -0
trailmark/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Trailmark: Parse source code into queryable graphs for security analysis."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Pre-analysis passes for Trailmark code graphs."""
2
+
3
+ from trailmark.analysis.preanalysis import run_preanalysis
4
+
5
+ __all__ = ["run_preanalysis"]
@@ -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
+ }