entrygraph 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 (102) hide show
  1. entrygraph/__init__.py +87 -0
  2. entrygraph/__main__.py +8 -0
  3. entrygraph/_version.py +24 -0
  4. entrygraph/api.py +549 -0
  5. entrygraph/cli/__init__.py +0 -0
  6. entrygraph/cli/main.py +387 -0
  7. entrygraph/cli/render.py +136 -0
  8. entrygraph/data/sinks/csharp.toml +106 -0
  9. entrygraph/data/sinks/go.toml +87 -0
  10. entrygraph/data/sinks/java.toml +92 -0
  11. entrygraph/data/sinks/javascript.toml +112 -0
  12. entrygraph/data/sinks/lib_javascript.toml +34 -0
  13. entrygraph/data/sinks/lib_python.toml +39 -0
  14. entrygraph/data/sinks/php.toml +125 -0
  15. entrygraph/data/sinks/python.toml +160 -0
  16. entrygraph/data/sinks/ruby.toml +102 -0
  17. entrygraph/data/sinks/rust.toml +68 -0
  18. entrygraph/db/__init__.py +0 -0
  19. entrygraph/db/engine.py +34 -0
  20. entrygraph/db/meta.py +70 -0
  21. entrygraph/db/models.py +176 -0
  22. entrygraph/db/queries.py +155 -0
  23. entrygraph/detect/__init__.py +0 -0
  24. entrygraph/detect/entrypoints/__init__.py +22 -0
  25. entrygraph/detect/entrypoints/base.py +124 -0
  26. entrygraph/detect/entrypoints/configs.py +139 -0
  27. entrygraph/detect/entrypoints/csharp.py +156 -0
  28. entrygraph/detect/entrypoints/golang.py +158 -0
  29. entrygraph/detect/entrypoints/java.py +187 -0
  30. entrygraph/detect/entrypoints/javascript.py +211 -0
  31. entrygraph/detect/entrypoints/php.py +133 -0
  32. entrygraph/detect/entrypoints/python.py +335 -0
  33. entrygraph/detect/entrypoints/ruby.py +147 -0
  34. entrygraph/detect/entrypoints/rust.py +153 -0
  35. entrygraph/detect/frameworks.py +369 -0
  36. entrygraph/detect/manifests.py +234 -0
  37. entrygraph/detect/taint.py +224 -0
  38. entrygraph/errors.py +27 -0
  39. entrygraph/extract/__init__.py +0 -0
  40. entrygraph/extract/base.py +51 -0
  41. entrygraph/extract/csharp.py +502 -0
  42. entrygraph/extract/golang.py +342 -0
  43. entrygraph/extract/ir.py +105 -0
  44. entrygraph/extract/java.py +329 -0
  45. entrygraph/extract/javascript.py +400 -0
  46. entrygraph/extract/php.py +426 -0
  47. entrygraph/extract/python.py +390 -0
  48. entrygraph/extract/registry.py +43 -0
  49. entrygraph/extract/ruby.py +321 -0
  50. entrygraph/extract/rust.py +482 -0
  51. entrygraph/fs/__init__.py +0 -0
  52. entrygraph/fs/hashing.py +78 -0
  53. entrygraph/fs/lang.py +134 -0
  54. entrygraph/fs/walker.py +167 -0
  55. entrygraph/graph/__init__.py +0 -0
  56. entrygraph/graph/adjacency.py +146 -0
  57. entrygraph/graph/cte.py +123 -0
  58. entrygraph/graph/scoring.py +101 -0
  59. entrygraph/kinds.py +51 -0
  60. entrygraph/parsing/__init__.py +0 -0
  61. entrygraph/parsing/parsers.py +49 -0
  62. entrygraph/parsing/queries.py +39 -0
  63. entrygraph/pipeline/__init__.py +0 -0
  64. entrygraph/pipeline/scanner.py +506 -0
  65. entrygraph/pipeline/worker.py +49 -0
  66. entrygraph/pipeline/writer.py +41 -0
  67. entrygraph/py.typed +0 -0
  68. entrygraph/queries/csharp/calls.scm +4 -0
  69. entrygraph/queries/csharp/definitions.scm +29 -0
  70. entrygraph/queries/csharp/imports.scm +4 -0
  71. entrygraph/queries/go/calls.scm +2 -0
  72. entrygraph/queries/go/definitions.scm +24 -0
  73. entrygraph/queries/go/imports.scm +1 -0
  74. entrygraph/queries/java/calls.scm +2 -0
  75. entrygraph/queries/java/definitions.scm +14 -0
  76. entrygraph/queries/java/imports.scm +2 -0
  77. entrygraph/queries/javascript/calls.scm +4 -0
  78. entrygraph/queries/javascript/definitions.scm +4 -0
  79. entrygraph/queries/javascript/imports.scm +6 -0
  80. entrygraph/queries/php/calls.scm +8 -0
  81. entrygraph/queries/php/definitions.scm +24 -0
  82. entrygraph/queries/php/imports.scm +1 -0
  83. entrygraph/queries/python/calls.scm +2 -0
  84. entrygraph/queries/python/definitions.scm +11 -0
  85. entrygraph/queries/python/imports.scm +2 -0
  86. entrygraph/queries/ruby/calls.scm +4 -0
  87. entrygraph/queries/ruby/definitions.scm +20 -0
  88. entrygraph/queries/ruby/imports.scm +7 -0
  89. entrygraph/queries/rust/calls.scm +5 -0
  90. entrygraph/queries/rust/definitions.scm +26 -0
  91. entrygraph/queries/rust/imports.scm +4 -0
  92. entrygraph/resolve/__init__.py +0 -0
  93. entrygraph/resolve/externals.py +61 -0
  94. entrygraph/resolve/hierarchy.py +152 -0
  95. entrygraph/resolve/resolver.py +275 -0
  96. entrygraph/resolve/symbol_table.py +48 -0
  97. entrygraph/results.py +138 -0
  98. entrygraph-0.1.0.dist-info/METADATA +204 -0
  99. entrygraph-0.1.0.dist-info/RECORD +102 -0
  100. entrygraph-0.1.0.dist-info/WHEEL +4 -0
  101. entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
  102. entrygraph-0.1.0.dist-info/licenses/LICENSE +21 -0
entrygraph/cli/main.py ADDED
@@ -0,0 +1,387 @@
1
+ """entrygraph command-line interface — a thin wrapper over CodeGraph.
2
+
3
+ All logic lives in the library; this module only translates arguments, calls
4
+ the API, and renders results. `main(argv)` returns a process exit code so it is
5
+ directly testable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+
15
+ from rich.panel import Panel
16
+ from rich.text import Text
17
+ from rich.tree import Tree
18
+
19
+ from entrygraph import CodeGraph, __version__
20
+ from entrygraph.cli import render
21
+ from entrygraph.cli.render import (
22
+ confidence_text,
23
+ console,
24
+ entrypoint_kind_text,
25
+ kind_text,
26
+ method_text,
27
+ risk_style,
28
+ risk_text,
29
+ to_json,
30
+ )
31
+ from entrygraph.errors import EntrygraphError
32
+
33
+ DEFAULT_DB_NAME = ".entrygraph.db"
34
+
35
+
36
+ def _discover_db(explicit: str | None) -> Path:
37
+ if explicit:
38
+ return Path(explicit)
39
+ current = Path.cwd()
40
+ for directory in (current, *current.parents):
41
+ candidate = directory / DEFAULT_DB_NAME
42
+ if candidate.exists():
43
+ return candidate
44
+ return current / DEFAULT_DB_NAME
45
+
46
+
47
+ def _open(args) -> CodeGraph:
48
+ return CodeGraph.open(_discover_db(getattr(args, "db", None)))
49
+
50
+
51
+ def _percent_bar(percent: float, width: int = 12) -> Text:
52
+ filled = round(percent / 100 * width)
53
+ style = "cyan" if percent >= 25 else "blue"
54
+ bar = Text("█" * filled, style=style)
55
+ bar.append("░" * (width - filled), style="dim")
56
+ bar.append(f" {percent:5.1f}%", style="")
57
+ return bar
58
+
59
+
60
+ def _confidence_bar(confidence: float, width: int = 10) -> Text:
61
+ filled = round(confidence * width)
62
+ style = "green" if confidence >= 0.8 else "yellow" if confidence >= 0.5 else "red"
63
+ bar = Text("█" * filled, style=style)
64
+ bar.append("░" * (width - filled), style="dim")
65
+ bar.append(f" {confidence:.2f}", style=style)
66
+ return bar
67
+
68
+
69
+ # ---------------- command handlers ----------------
70
+
71
+ def cmd_index(args) -> int:
72
+ from entrygraph.pipeline.scanner import index_repository
73
+
74
+ root = Path(args.path).resolve()
75
+ db = args.db or (root / DEFAULT_DB_NAME)
76
+ con = console()
77
+
78
+ def _run():
79
+ if getattr(args, "full", False) or not Path(db).exists():
80
+ graph = CodeGraph.index(root, db=db)
81
+ stats = graph._last_index_stats
82
+ graph.close()
83
+ return stats
84
+ with CodeGraph.open(db) as graph:
85
+ return index_repository(root, graph._engine, incremental=True,
86
+ paranoid=args.paranoid)
87
+
88
+ if args.json:
89
+ print(to_json(_run()))
90
+ return 0
91
+
92
+ mode = "full re-index" if getattr(args, "full", False) else "index"
93
+ with con.status(f"[bold]Running {mode}[/] on [cyan]{root}[/]…", spinner="dots"):
94
+ stats = _run()
95
+
96
+ body = Text()
97
+ body.append("files ", style="bold")
98
+ body.append(f"{stats.files_indexed} indexed", style="green")
99
+ body.append(f", {stats.files_skipped} skipped, {stats.files_deleted} deleted "
100
+ f"of {stats.files_scanned} scanned\n", style="dim")
101
+ body.append("graph ", style="bold")
102
+ body.append(f"{stats.symbols} ", style="cyan")
103
+ body.append("symbols ", style="dim")
104
+ body.append(f"{stats.edges} ", style="cyan")
105
+ body.append("edges ", style="dim")
106
+ body.append(f"{stats.entrypoints} ", style="cyan")
107
+ body.append("entrypoints\n", style="dim")
108
+ body.append("db ", style="bold")
109
+ body.append(f"{db}", style="")
110
+ con.print(Panel(body, title=f"[bold green]✓[/] indexed [cyan]{root.name}[/]",
111
+ subtitle=f"[dim]{stats.duration_seconds}s[/]",
112
+ border_style="green", expand=False))
113
+ return 0
114
+
115
+
116
+ def cmd_detect(args) -> int:
117
+ with _open(args) as graph:
118
+ report = graph.detect()
119
+ if args.json:
120
+ print(to_json(report))
121
+ return 0
122
+ con = console()
123
+
124
+ langs = render.table("Languages")
125
+ langs.add_column("LANGUAGE", style="bold")
126
+ langs.add_column("FILES", justify="right")
127
+ langs.add_column("SHARE")
128
+ for l in report.languages:
129
+ langs.add_row(l.name, str(l.file_count), _percent_bar(l.percent))
130
+ con.print(langs)
131
+
132
+ if report.frameworks:
133
+ fw = render.table("Frameworks")
134
+ fw.add_column("FRAMEWORK", style="bold magenta")
135
+ fw.add_column("LANGUAGE", style="dim")
136
+ fw.add_column("CONFIDENCE")
137
+ for f in report.frameworks:
138
+ fw.add_row(f.name, f.language, _confidence_bar(f.confidence))
139
+ con.print(fw)
140
+ else:
141
+ con.print("[dim]No frameworks detected.[/]")
142
+ return 0
143
+
144
+
145
+ def _print_symbol_table(rows, *, with_line: bool) -> None:
146
+ con = console()
147
+ if not rows:
148
+ con.print("[dim](no results)[/]")
149
+ return
150
+ tbl = render.table()
151
+ tbl.add_column("KIND", no_wrap=True)
152
+ tbl.add_column("QNAME", style="bold", no_wrap=True)
153
+ tbl.add_column("FILE", style="dim", overflow="fold")
154
+ if with_line:
155
+ tbl.add_column("LINE", justify="right", style="dim", no_wrap=True)
156
+ for r in rows:
157
+ cells = [kind_text(r.kind), r.qname, render.cell(r.file)]
158
+ if with_line:
159
+ cells.append(str(r.start_line))
160
+ tbl.add_row(*cells)
161
+ con.print(tbl)
162
+
163
+
164
+ def cmd_symbols(args) -> int:
165
+ with _open(args) as graph:
166
+ rows = graph.symbols(kind=args.kind, name=args.name, qname=args.qname,
167
+ file=args.file, limit=args.limit)
168
+ if args.json:
169
+ print(to_json(rows))
170
+ else:
171
+ _print_symbol_table(rows, with_line=True)
172
+ return 0
173
+
174
+
175
+ def cmd_entrypoints(args) -> int:
176
+ with _open(args) as graph:
177
+ rows = graph.entrypoints(kind=args.kind, framework=args.framework, route=args.route)
178
+ if args.json:
179
+ print(to_json(rows))
180
+ return 0
181
+ con = console()
182
+ if not rows:
183
+ con.print("[dim](no entrypoints)[/]")
184
+ return 0
185
+ tbl = render.table(caption=f"[dim]{len(rows)} entrypoint(s)[/]")
186
+ # short columns are protected from squeeze; HANDLER wraps if the terminal is narrow
187
+ tbl.add_column("KIND", no_wrap=True)
188
+ tbl.add_column("FRAMEWORK", style="magenta", no_wrap=True)
189
+ tbl.add_column("METHOD", no_wrap=True)
190
+ tbl.add_column("ROUTE", style="bold", no_wrap=True)
191
+ tbl.add_column("HANDLER", style="dim", overflow="fold")
192
+ for r in rows:
193
+ tbl.add_row(entrypoint_kind_text(r.kind), render.cell(r.framework),
194
+ method_text(r.http_method), render.cell(r.route), r.symbol.qname)
195
+ con.print(tbl)
196
+ return 0
197
+
198
+
199
+ def cmd_callers(args) -> int:
200
+ with _open(args) as graph:
201
+ rows = graph.callers(args.qname, depth=args.depth)
202
+ if args.json:
203
+ print(to_json(rows))
204
+ else:
205
+ _print_symbol_table(rows, with_line=False)
206
+ return 0
207
+
208
+
209
+ def cmd_callees(args) -> int:
210
+ with _open(args) as graph:
211
+ rows = graph.callees(args.qname, depth=args.depth)
212
+ if args.json:
213
+ print(to_json(rows))
214
+ else:
215
+ _print_symbol_table(rows, with_line=False)
216
+ return 0
217
+
218
+
219
+ def _path_tree(index: int, path) -> Tree:
220
+ src = path.symbols[0]
221
+ header = Text()
222
+ header.append(f"[{index}] ", style="dim")
223
+ header.append("■ ", style=risk_style(path.risk_score))
224
+ header.append("risk ", style="dim")
225
+ header.append(risk_text(path.risk_score))
226
+ header.append(f" {src.qname}", style="bold")
227
+ tree = Tree(header, guide_style="dim")
228
+ node = tree
229
+ for edge, sym in zip(path.edges, path.symbols[1:]):
230
+ is_sink = sym is path.symbols[-1]
231
+ label = Text()
232
+ label.append("→ ", style="dim")
233
+ label.append(sym.qname, style="bold red" if is_sink else kind_text(sym.kind).style)
234
+ label.append(f" line {edge.line}", style="dim")
235
+ label.append(" ")
236
+ label.append_text(confidence_text(edge.confidence))
237
+ if edge.via:
238
+ label.append(f" via {edge.via}", style="dim italic")
239
+ if is_sink and edge.sink_id:
240
+ label.append(f" ⚑ {edge.sink_id}", style="red")
241
+ if is_sink and edge.constant_args:
242
+ label.append(" [const-args]", style="dim green")
243
+ node = node.add(label)
244
+ if path.may_continue:
245
+ node.add(Text("… may continue (dynamic/excluded edges)", style="dim italic yellow"))
246
+ return tree
247
+
248
+
249
+ def cmd_paths(args) -> int:
250
+ with _open(args) as graph:
251
+ paths = graph.paths(
252
+ source=args.source,
253
+ sink=args.sink,
254
+ sink_category=args.sink_category,
255
+ max_depth=args.max_depth,
256
+ max_paths=args.max_paths,
257
+ min_confidence=args.min_confidence,
258
+ include_fuzzy=args.include_fuzzy,
259
+ include_unresolved=args.include_unresolved,
260
+ prune_sanitized=args.prune_sanitized,
261
+ )
262
+ if args.json:
263
+ print(to_json([
264
+ {"length": len(p.symbols), "min_confidence": p.min_confidence,
265
+ "risk_score": p.risk_score, "may_continue": p.may_continue,
266
+ "symbols": [s.qname for s in p.symbols],
267
+ "lines": [e.line for e in p.edges]}
268
+ for p in paths
269
+ ]))
270
+ return 0 if paths else 1
271
+
272
+ con = console()
273
+ if not paths:
274
+ con.print(Panel("[yellow]No source → sink paths found.[/]",
275
+ border_style="yellow", expand=False))
276
+ return 1
277
+ target = args.sink or (args.sink_category and f"category:{args.sink_category}") or "sink"
278
+ con.print(f"[bold]{len(paths)}[/] path(s) [dim]{args.source} → {target}[/]\n")
279
+ for i, path in enumerate(paths, 1):
280
+ con.print(_path_tree(i, path))
281
+ return 0
282
+
283
+
284
+ def cmd_stats(args) -> int:
285
+ with _open(args) as graph:
286
+ stats = graph.stats()
287
+ if args.json:
288
+ print(to_json(stats))
289
+ return 0
290
+ con = console()
291
+ grid = render.table()
292
+ grid.add_column("metric", style="dim")
293
+ grid.add_column("value", justify="right", style="bold cyan")
294
+ for key, value in asdict(stats).items():
295
+ grid.add_row(key, str(value))
296
+ con.print(Panel(grid, title="[bold]index stats[/]", border_style="cyan", expand=False))
297
+ return 0
298
+
299
+
300
+ # ---------------- parser ----------------
301
+
302
+ def build_parser() -> argparse.ArgumentParser:
303
+ parser = argparse.ArgumentParser(prog="entrygraph", description=__doc__.splitlines()[0])
304
+ parser.add_argument("--version", action="version", version=f"entrygraph {__version__}")
305
+ sub = parser.add_subparsers(dest="command", required=True)
306
+
307
+ def add_db(p):
308
+ p.add_argument("--db", help=f"index database path (default: discover {DEFAULT_DB_NAME})")
309
+ p.add_argument("--json", action="store_true", help="emit JSON")
310
+
311
+ p = sub.add_parser("index", help="index or re-index a repository")
312
+ p.add_argument("path")
313
+ p.add_argument("--db", help=f"database path (default: <path>/{DEFAULT_DB_NAME})")
314
+ p.add_argument("--full", action="store_true", help="force full re-index (default: incremental)")
315
+ p.add_argument("--paranoid", action="store_true", help="hash every file (skip mtime fast path)")
316
+ p.add_argument("--json", action="store_true")
317
+ p.set_defaults(func=cmd_index)
318
+
319
+ p = sub.add_parser("detect", help="show detected languages and frameworks")
320
+ add_db(p)
321
+ p.set_defaults(func=cmd_detect)
322
+
323
+ p = sub.add_parser("symbols", help="list symbols")
324
+ add_db(p)
325
+ p.add_argument("--kind")
326
+ p.add_argument("--name")
327
+ p.add_argument("--qname")
328
+ p.add_argument("--file")
329
+ p.add_argument("--limit", type=int)
330
+ p.set_defaults(func=cmd_symbols)
331
+
332
+ p = sub.add_parser("entrypoints", help="list entrypoints")
333
+ add_db(p)
334
+ p.add_argument("--kind")
335
+ p.add_argument("--framework")
336
+ p.add_argument("--route")
337
+ p.set_defaults(func=cmd_entrypoints)
338
+
339
+ p = sub.add_parser("callers", help="who calls this symbol")
340
+ add_db(p)
341
+ p.add_argument("qname")
342
+ p.add_argument("--depth", type=int, default=1)
343
+ p.set_defaults(func=cmd_callers)
344
+
345
+ p = sub.add_parser("callees", help="what this symbol calls")
346
+ add_db(p)
347
+ p.add_argument("qname")
348
+ p.add_argument("--depth", type=int, default=1)
349
+ p.set_defaults(func=cmd_callees)
350
+
351
+ p = sub.add_parser("paths", help="source -> sink call paths")
352
+ add_db(p)
353
+ p.add_argument("--source", required=True, help="qname or glob")
354
+ p.add_argument("--sink", help="qname or glob (e.g. py:subprocess.run)")
355
+ p.add_argument("--sink-category", dest="sink_category",
356
+ help="named sink category (e.g. command_exec, sql)")
357
+ p.add_argument("--max-depth", dest="max_depth", type=int, default=25)
358
+ p.add_argument("--max-paths", dest="max_paths", type=int, default=10)
359
+ p.add_argument("--min-confidence", dest="min_confidence", type=int, default=None,
360
+ help="explicit confidence floor (overrides --include-* flags)")
361
+ p.add_argument("--include-fuzzy", dest="include_fuzzy", action="store_true",
362
+ help="also traverse speculative class-hierarchy (CHA) edges")
363
+ p.add_argument("--include-unresolved", dest="include_unresolved", action="store_true",
364
+ help="also traverse unresolved wildcard-sink and dynamic-call edges")
365
+ p.add_argument("--prune-sanitized", dest="prune_sanitized", action="store_true",
366
+ help="drop paths neutralized by a registered sanitizer")
367
+ p.set_defaults(func=cmd_paths)
368
+
369
+ p = sub.add_parser("stats", help="index statistics")
370
+ add_db(p)
371
+ p.set_defaults(func=cmd_stats)
372
+
373
+ return parser
374
+
375
+
376
+ def main(argv: list[str] | None = None) -> int:
377
+ parser = build_parser()
378
+ args = parser.parse_args(argv)
379
+ try:
380
+ return args.func(args)
381
+ except EntrygraphError as exc:
382
+ console(stderr=True).print(Text(f"error: {exc}", style="bold red"))
383
+ return 2
384
+
385
+
386
+ if __name__ == "__main__": # pragma: no cover
387
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ """Rich-powered output rendering for the CLI, plus plain JSON.
2
+
3
+ Human-readable output goes through Rich (colored tables, trees, panels); the
4
+ ``--json`` path stays plain so piped/consumed output is untouched. Consoles are
5
+ created per call so they bind to the current ``sys.stdout`` (important under
6
+ pytest's capture) and, when output is not a TTY, use a very wide virtual width
7
+ so long qualified names are never truncated or wrapped mid-token.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from dataclasses import asdict, is_dataclass
15
+ from typing import Any, Sequence
16
+
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+ # Width used when stdout is redirected (pipe/file/capture): large enough that
22
+ # Rich never truncates a cell with an ellipsis or folds a qname.
23
+ _PIPE_WIDTH = 10_000
24
+
25
+
26
+ def console(*, stderr: bool = False) -> Console:
27
+ """A fresh Console bound to the current stream."""
28
+ stream = sys.stderr if stderr else sys.stdout
29
+ is_tty = bool(getattr(stream, "isatty", lambda: False)())
30
+ return Console(
31
+ file=stream,
32
+ stderr=stderr,
33
+ highlight=False,
34
+ soft_wrap=not is_tty,
35
+ width=None if is_tty else _PIPE_WIDTH,
36
+ )
37
+
38
+
39
+ # ---------------- JSON (unchanged, dependency-free) ----------------
40
+
41
+ def to_json(obj: Any) -> str:
42
+ def default(o):
43
+ if is_dataclass(o):
44
+ return asdict(o)
45
+ raise TypeError(f"not serializable: {type(o)}")
46
+
47
+ return json.dumps(obj, default=default, indent=2)
48
+
49
+
50
+ # ---------------- styling helpers ----------------
51
+
52
+ _KIND_STYLE = {
53
+ "class": "bold cyan", "interface": "cyan", "struct": "cyan",
54
+ "function": "green", "method": "green",
55
+ "module": "blue", "variable": "yellow", "constant": "yellow",
56
+ "field": "yellow", "property": "yellow", "external": "dim red",
57
+ }
58
+
59
+ _METHOD_STYLE = {
60
+ "GET": "green", "POST": "yellow", "PUT": "blue", "PATCH": "blue",
61
+ "DELETE": "red", "OPTIONS": "dim", "HEAD": "dim", "*": "magenta",
62
+ }
63
+
64
+ _ENTRYPOINT_STYLE = {
65
+ "http_route": "green", "cli_command": "cyan", "main": "blue",
66
+ "task": "magenta", "lambda_handler": "yellow", "event_handler": "yellow",
67
+ "middleware": "dim yellow",
68
+ }
69
+
70
+ _CONFIDENCE_NAME = {0: "unresolved", 1: "fuzzy", 2: "import", 3: "exact"}
71
+ _CONFIDENCE_STYLE = {0: "dim red", 1: "yellow", 2: "green", 3: "bold green"}
72
+
73
+
74
+ def kind_text(kind: str | None) -> Text:
75
+ return Text(kind or "", style=_KIND_STYLE.get(kind or "", ""))
76
+
77
+
78
+ def method_text(method: str | None) -> Text:
79
+ if not method:
80
+ return Text("")
81
+ parts = method.split(",")
82
+ text = Text()
83
+ for i, m in enumerate(parts):
84
+ if i:
85
+ text.append(",", style="dim")
86
+ text.append(m, style=_METHOD_STYLE.get(m.strip().upper(), ""))
87
+ return text
88
+
89
+
90
+ def entrypoint_kind_text(kind: str | None) -> Text:
91
+ return Text(kind or "", style=_ENTRYPOINT_STYLE.get(kind or "", ""))
92
+
93
+
94
+ def confidence_text(value: int) -> Text:
95
+ return Text(_CONFIDENCE_NAME.get(value, str(value)),
96
+ style=_CONFIDENCE_STYLE.get(value, ""))
97
+
98
+
99
+ def risk_style(risk: float | None) -> str:
100
+ """Color a risk score: red (high) -> yellow -> green (low)."""
101
+ if risk is None:
102
+ return "dim"
103
+ if risk >= 0.66:
104
+ return "bold red"
105
+ if risk >= 0.33:
106
+ return "yellow"
107
+ return "green"
108
+
109
+
110
+ def risk_text(risk: float | None) -> Text:
111
+ if risk is None:
112
+ return Text("—", style="dim")
113
+ return Text(f"{risk:.2f}", style=risk_style(risk))
114
+
115
+
116
+ # ---------------- table helper ----------------
117
+
118
+ def table(title: str | None = None, *, caption: str | None = None) -> Table:
119
+ """A consistently-styled Rich table. Header cells are upper-cased."""
120
+ return Table(
121
+ title=title,
122
+ caption=caption,
123
+ title_style="bold",
124
+ header_style="bold white",
125
+ border_style="dim",
126
+ expand=False,
127
+ pad_edge=False,
128
+ )
129
+
130
+
131
+ def cell(value: Any) -> str:
132
+ if value is None:
133
+ return ""
134
+ if isinstance(value, bool):
135
+ return "yes" if value else "no"
136
+ return str(value)
@@ -0,0 +1,106 @@
1
+ # Built-in C# (.NET) sink/source catalog.
2
+ # Patterns are brace-globs matched against canonical callee qnames:
3
+ # resolved external calls -> "cs:System.Diagnostics.Process.Start"
4
+ # unresolved attribute calls -> "cs:*.ExecuteReader" (unknown receiver)
5
+ # unresolved bare/type calls -> "cs:Process.Start" / "cs:SqlCommand"
6
+ #
7
+ # Static calls (Process.Start) and receiver-agnostic member calls
8
+ # (cmd.ExecuteReader) land on different guesses, so most entries carry both a
9
+ # bare (`cs:Type.Method`) and a receiver-agnostic (`cs:*.Method`) form. Object
10
+ # creations (`new SqlCommand()`) key on the constructed type name.
11
+
12
+ [[sink]]
13
+ id = "csharp.command-exec"
14
+ category = "command_exec"
15
+ severity = "high"
16
+ callee = "cs:{Process.Start,*.Process.Start,ProcessStartInfo,*.ProcessStartInfo}"
17
+ description = "OS command execution (Process.Start / ProcessStartInfo)"
18
+
19
+ [[sink]]
20
+ id = "csharp.sql-command"
21
+ category = "sql"
22
+ severity = "medium"
23
+ callee = "cs:{SqlCommand,*.SqlCommand,MySqlCommand,NpgsqlCommand,SqliteCommand}"
24
+ description = "SQL command construction (ADO.NET command types)"
25
+
26
+ [[sink]]
27
+ id = "csharp.sql-execute"
28
+ category = "sql"
29
+ severity = "medium"
30
+ callee = "cs:*.{ExecuteReader,ExecuteNonQuery,ExecuteScalar,ExecuteReaderAsync,ExecuteNonQueryAsync}"
31
+ description = "SQL statement execution (receiver-agnostic)"
32
+
33
+ [[sink]]
34
+ id = "csharp.sql-ef-raw"
35
+ category = "sql"
36
+ severity = "medium"
37
+ callee = "cs:*.{FromSqlRaw,ExecuteSqlRaw,ExecuteSqlRawAsync}"
38
+ description = "EF Core raw SQL execution"
39
+
40
+ [[sink]]
41
+ id = "csharp.deserialization"
42
+ category = "deserialization"
43
+ severity = "medium"
44
+ callee = "cs:{BinaryFormatter,*.BinaryFormatter,SoapFormatter,NetDataContractSerializer,ObjectStateFormatter,LosFormatter}"
45
+ description = "Unsafe .NET deserialization formatter"
46
+
47
+ [[sink]]
48
+ id = "csharp.deserialization.call"
49
+ category = "deserialization"
50
+ severity = "medium"
51
+ callee = "cs:*.{Deserialize,ReadObject}"
52
+ description = "Deserialization call (Formatter.Deserialize / DataContract.ReadObject)"
53
+
54
+ [[sink]]
55
+ id = "csharp.code-eval"
56
+ category = "code_eval"
57
+ severity = "critical"
58
+ callee = "cs:{CSharpScript.EvaluateAsync,Assembly.Load,*.Assembly.Load,Activator.CreateInstance,*.Activator.CreateInstance}"
59
+ description = "Dynamic code evaluation / dynamic type loading"
60
+
61
+ [[sink]]
62
+ id = "csharp.xxe"
63
+ category = "xxe"
64
+ severity = "medium"
65
+ callee = "cs:{XmlDocument,*.XmlDocument,XmlTextReader}"
66
+ description = "XML parser susceptible to XXE (XmlDocument / XmlTextReader)"
67
+
68
+ [[sink]]
69
+ id = "csharp.xxe.loadxml"
70
+ category = "xxe"
71
+ severity = "medium"
72
+ callee = "cs:*.LoadXml"
73
+ description = "XML load with a potentially attacker-controlled document"
74
+
75
+ [[sink]]
76
+ id = "csharp.path-traversal"
77
+ category = "path_traversal"
78
+ severity = "medium"
79
+ callee = "cs:File.{WriteAllText,WriteAllBytes,Delete,Move,ReadAllText,ReadAllBytes}"
80
+ description = "File access with a potentially attacker-controlled path"
81
+
82
+ [[sink]]
83
+ id = "csharp.ssrf"
84
+ category = "ssrf"
85
+ severity = "medium"
86
+ callee = "cs:{*.GetAsync,*.PostAsync,*.SendAsync,WebRequest.Create}"
87
+ description = "Outbound HTTP request (HttpClient / WebRequest)"
88
+
89
+ [[sink]]
90
+ id = "csharp.ldap"
91
+ category = "ldap"
92
+ severity = "medium"
93
+ callee = "cs:{DirectorySearcher,*.DirectorySearcher}"
94
+ description = "LDAP query (DirectorySearcher)"
95
+
96
+ [[source]]
97
+ id = "csharp.input"
98
+ category = "http_input"
99
+ callee = "cs:*.{ReadLine,ReadFromJsonAsync,ReadAsStringAsync}"
100
+ description = "Request/console input"
101
+
102
+ [[source]]
103
+ id = "csharp.env"
104
+ category = "env_input"
105
+ callee = "cs:Environment.GetEnvironmentVariable"
106
+ description = "Environment variable input"