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.
- entrygraph/__init__.py +87 -0
- entrygraph/__main__.py +8 -0
- entrygraph/_version.py +24 -0
- entrygraph/api.py +549 -0
- entrygraph/cli/__init__.py +0 -0
- entrygraph/cli/main.py +387 -0
- entrygraph/cli/render.py +136 -0
- entrygraph/data/sinks/csharp.toml +106 -0
- entrygraph/data/sinks/go.toml +87 -0
- entrygraph/data/sinks/java.toml +92 -0
- entrygraph/data/sinks/javascript.toml +112 -0
- entrygraph/data/sinks/lib_javascript.toml +34 -0
- entrygraph/data/sinks/lib_python.toml +39 -0
- entrygraph/data/sinks/php.toml +125 -0
- entrygraph/data/sinks/python.toml +160 -0
- entrygraph/data/sinks/ruby.toml +102 -0
- entrygraph/data/sinks/rust.toml +68 -0
- entrygraph/db/__init__.py +0 -0
- entrygraph/db/engine.py +34 -0
- entrygraph/db/meta.py +70 -0
- entrygraph/db/models.py +176 -0
- entrygraph/db/queries.py +155 -0
- entrygraph/detect/__init__.py +0 -0
- entrygraph/detect/entrypoints/__init__.py +22 -0
- entrygraph/detect/entrypoints/base.py +124 -0
- entrygraph/detect/entrypoints/configs.py +139 -0
- entrygraph/detect/entrypoints/csharp.py +156 -0
- entrygraph/detect/entrypoints/golang.py +158 -0
- entrygraph/detect/entrypoints/java.py +187 -0
- entrygraph/detect/entrypoints/javascript.py +211 -0
- entrygraph/detect/entrypoints/php.py +133 -0
- entrygraph/detect/entrypoints/python.py +335 -0
- entrygraph/detect/entrypoints/ruby.py +147 -0
- entrygraph/detect/entrypoints/rust.py +153 -0
- entrygraph/detect/frameworks.py +369 -0
- entrygraph/detect/manifests.py +234 -0
- entrygraph/detect/taint.py +224 -0
- entrygraph/errors.py +27 -0
- entrygraph/extract/__init__.py +0 -0
- entrygraph/extract/base.py +51 -0
- entrygraph/extract/csharp.py +502 -0
- entrygraph/extract/golang.py +342 -0
- entrygraph/extract/ir.py +105 -0
- entrygraph/extract/java.py +329 -0
- entrygraph/extract/javascript.py +400 -0
- entrygraph/extract/php.py +426 -0
- entrygraph/extract/python.py +390 -0
- entrygraph/extract/registry.py +43 -0
- entrygraph/extract/ruby.py +321 -0
- entrygraph/extract/rust.py +482 -0
- entrygraph/fs/__init__.py +0 -0
- entrygraph/fs/hashing.py +78 -0
- entrygraph/fs/lang.py +134 -0
- entrygraph/fs/walker.py +167 -0
- entrygraph/graph/__init__.py +0 -0
- entrygraph/graph/adjacency.py +146 -0
- entrygraph/graph/cte.py +123 -0
- entrygraph/graph/scoring.py +101 -0
- entrygraph/kinds.py +51 -0
- entrygraph/parsing/__init__.py +0 -0
- entrygraph/parsing/parsers.py +49 -0
- entrygraph/parsing/queries.py +39 -0
- entrygraph/pipeline/__init__.py +0 -0
- entrygraph/pipeline/scanner.py +506 -0
- entrygraph/pipeline/worker.py +49 -0
- entrygraph/pipeline/writer.py +41 -0
- entrygraph/py.typed +0 -0
- entrygraph/queries/csharp/calls.scm +4 -0
- entrygraph/queries/csharp/definitions.scm +29 -0
- entrygraph/queries/csharp/imports.scm +4 -0
- entrygraph/queries/go/calls.scm +2 -0
- entrygraph/queries/go/definitions.scm +24 -0
- entrygraph/queries/go/imports.scm +1 -0
- entrygraph/queries/java/calls.scm +2 -0
- entrygraph/queries/java/definitions.scm +14 -0
- entrygraph/queries/java/imports.scm +2 -0
- entrygraph/queries/javascript/calls.scm +4 -0
- entrygraph/queries/javascript/definitions.scm +4 -0
- entrygraph/queries/javascript/imports.scm +6 -0
- entrygraph/queries/php/calls.scm +8 -0
- entrygraph/queries/php/definitions.scm +24 -0
- entrygraph/queries/php/imports.scm +1 -0
- entrygraph/queries/python/calls.scm +2 -0
- entrygraph/queries/python/definitions.scm +11 -0
- entrygraph/queries/python/imports.scm +2 -0
- entrygraph/queries/ruby/calls.scm +4 -0
- entrygraph/queries/ruby/definitions.scm +20 -0
- entrygraph/queries/ruby/imports.scm +7 -0
- entrygraph/queries/rust/calls.scm +5 -0
- entrygraph/queries/rust/definitions.scm +26 -0
- entrygraph/queries/rust/imports.scm +4 -0
- entrygraph/resolve/__init__.py +0 -0
- entrygraph/resolve/externals.py +61 -0
- entrygraph/resolve/hierarchy.py +152 -0
- entrygraph/resolve/resolver.py +275 -0
- entrygraph/resolve/symbol_table.py +48 -0
- entrygraph/results.py +138 -0
- entrygraph-0.1.0.dist-info/METADATA +204 -0
- entrygraph-0.1.0.dist-info/RECORD +102 -0
- entrygraph-0.1.0.dist-info/WHEEL +4 -0
- entrygraph-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|
entrygraph/cli/render.py
ADDED
|
@@ -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"
|