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/fs/walker.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Repository file walk with pruning, .gitignore support, and content gates.
|
|
2
|
+
|
|
3
|
+
Order of gates (cheapest first):
|
|
4
|
+
1. hard-pruned directory names (checked before pathspec — pruning node_modules
|
|
5
|
+
at the directory level is the single biggest walk speedup),
|
|
6
|
+
2. .gitignore rules (root + nested, via pathspec),
|
|
7
|
+
3. per-file gates: size cap, NUL-byte binary sniff, minified-JS heuristics.
|
|
8
|
+
|
|
9
|
+
Every skipped-but-recognized file is reported with a reason so "why isn't my
|
|
10
|
+
file indexed" is always answerable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pathspec
|
|
20
|
+
|
|
21
|
+
from entrygraph.fs.lang import RepoLanguageProfile, detect_language
|
|
22
|
+
|
|
23
|
+
PRUNED_DIRS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
".git", ".hg", ".svn",
|
|
26
|
+
"node_modules", "bower_components",
|
|
27
|
+
".venv", "venv", ".tox", "__pycache__", ".mypy_cache", ".ruff_cache",
|
|
28
|
+
".pytest_cache", "site-packages", ".eggs",
|
|
29
|
+
"vendor", "third_party",
|
|
30
|
+
"dist", "build", "target", "out",
|
|
31
|
+
".next", ".nuxt", ".gradle", ".idea", ".vscode",
|
|
32
|
+
"Pods", "DerivedData",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MAX_FILE_BYTES = 2 * 1024 * 1024 # 2 MiB
|
|
37
|
+
_MINIFIED_SUFFIXES = (".min.js", ".min.css", ".bundle.js", ".map")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class WalkedFile:
|
|
42
|
+
path: str # repo-relative, posix separators
|
|
43
|
+
abs_path: str
|
|
44
|
+
language: str | None
|
|
45
|
+
size_bytes: int
|
|
46
|
+
mtime_ns: int
|
|
47
|
+
skip_reason: str | None = None # too_large | binary | minified | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_gitignore(root: Path) -> pathspec.GitIgnoreSpec | None:
|
|
51
|
+
patterns: list[str] = []
|
|
52
|
+
for ignore_file in sorted(root.rglob(".gitignore")):
|
|
53
|
+
try:
|
|
54
|
+
rel_dir = ignore_file.parent.relative_to(root).as_posix()
|
|
55
|
+
except ValueError: # pragma: no cover - symlink escape
|
|
56
|
+
continue
|
|
57
|
+
prefix = "" if rel_dir == "." else rel_dir + "/"
|
|
58
|
+
try:
|
|
59
|
+
lines = ignore_file.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
60
|
+
except OSError:
|
|
61
|
+
continue
|
|
62
|
+
for line in lines:
|
|
63
|
+
stripped = line.strip()
|
|
64
|
+
if not stripped or stripped.startswith("#"):
|
|
65
|
+
continue
|
|
66
|
+
if prefix:
|
|
67
|
+
# scope nested .gitignore patterns to their directory
|
|
68
|
+
negate = stripped.startswith("!")
|
|
69
|
+
body = stripped[1:] if negate else stripped
|
|
70
|
+
anchored = body.lstrip("/")
|
|
71
|
+
scoped = f"{prefix}{anchored}" if body.startswith("/") else f"{prefix}**/{anchored}"
|
|
72
|
+
patterns.append(("!" if negate else "") + scoped)
|
|
73
|
+
else:
|
|
74
|
+
patterns.append(stripped)
|
|
75
|
+
if not patterns:
|
|
76
|
+
return None
|
|
77
|
+
return pathspec.GitIgnoreSpec.from_lines(patterns)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _content_gate(abs_path: str, language: str | None, size_bytes: int) -> str | None:
|
|
81
|
+
"""Return a skip reason, or None if the file should be parsed."""
|
|
82
|
+
if size_bytes > MAX_FILE_BYTES:
|
|
83
|
+
return "too_large"
|
|
84
|
+
name = os.path.basename(abs_path)
|
|
85
|
+
if name.endswith(_MINIFIED_SUFFIXES):
|
|
86
|
+
return "minified"
|
|
87
|
+
try:
|
|
88
|
+
with open(abs_path, "rb") as fh:
|
|
89
|
+
head = fh.read(8192)
|
|
90
|
+
except OSError:
|
|
91
|
+
return "unreadable"
|
|
92
|
+
if b"\x00" in head:
|
|
93
|
+
return "binary"
|
|
94
|
+
if language in ("javascript", "typescript", "tsx"):
|
|
95
|
+
lines = head.splitlines()
|
|
96
|
+
if any(len(line) > 5000 for line in lines):
|
|
97
|
+
return "minified"
|
|
98
|
+
if size_bytes > 50 * 1024 and lines and (len(head) / max(len(lines), 1)) > 250:
|
|
99
|
+
return "minified"
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def walk_repo(root: str | Path) -> tuple[list[WalkedFile], RepoLanguageProfile]:
|
|
104
|
+
"""Walk a repository, returning candidate files and a language profile.
|
|
105
|
+
|
|
106
|
+
Files in unrecognized languages are omitted; recognized-but-gated files are
|
|
107
|
+
included with ``skip_reason`` set so they can be recorded in the DB.
|
|
108
|
+
"""
|
|
109
|
+
root = Path(root).resolve()
|
|
110
|
+
spec = _load_gitignore(root)
|
|
111
|
+
profile = RepoLanguageProfile()
|
|
112
|
+
results: list[WalkedFile] = []
|
|
113
|
+
|
|
114
|
+
stack = [str(root)]
|
|
115
|
+
while stack:
|
|
116
|
+
current = stack.pop()
|
|
117
|
+
try:
|
|
118
|
+
entries = list(os.scandir(current))
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
for entry in entries:
|
|
122
|
+
name = entry.name
|
|
123
|
+
try:
|
|
124
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
125
|
+
except OSError:
|
|
126
|
+
continue
|
|
127
|
+
if is_dir:
|
|
128
|
+
if name in PRUNED_DIRS or name.startswith(".git"):
|
|
129
|
+
continue
|
|
130
|
+
rel = os.path.relpath(entry.path, root).replace(os.sep, "/")
|
|
131
|
+
if spec and spec.match_file(rel + "/"):
|
|
132
|
+
continue
|
|
133
|
+
stack.append(entry.path)
|
|
134
|
+
continue
|
|
135
|
+
if not entry.is_file(follow_symlinks=False):
|
|
136
|
+
continue
|
|
137
|
+
rel = os.path.relpath(entry.path, root).replace(os.sep, "/")
|
|
138
|
+
if spec and spec.match_file(rel):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
language = detect_language(rel)
|
|
142
|
+
if language is None:
|
|
143
|
+
# one cheap read for shebang sniffing of extensionless files
|
|
144
|
+
if "." not in name:
|
|
145
|
+
try:
|
|
146
|
+
with open(entry.path, "rb") as fh:
|
|
147
|
+
language = detect_language(rel, fh.readline(256))
|
|
148
|
+
except OSError:
|
|
149
|
+
continue
|
|
150
|
+
if language is None:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
stat = entry.stat(follow_symlinks=False)
|
|
154
|
+
profile.add(language, stat.st_size)
|
|
155
|
+
results.append(
|
|
156
|
+
WalkedFile(
|
|
157
|
+
path=rel,
|
|
158
|
+
abs_path=entry.path,
|
|
159
|
+
language=language,
|
|
160
|
+
size_bytes=stat.st_size,
|
|
161
|
+
mtime_ns=stat.st_mtime_ns,
|
|
162
|
+
skip_reason=_content_gate(entry.path, language, stat.st_size),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
results.sort(key=lambda f: f.path)
|
|
167
|
+
return results, profile
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""In-memory adjacency over the edges table — the primary reachability engine.
|
|
2
|
+
|
|
3
|
+
One indexed scan loads all resolved edges of the requested kinds into forward
|
|
4
|
+
and reverse adjacency dicts; every subsequent traversal is pure-Python BFS/DFS.
|
|
5
|
+
The cache is keyed by (edge kinds, index generation) and dropped on re-index.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import select
|
|
14
|
+
from sqlalchemy.orm import Session
|
|
15
|
+
|
|
16
|
+
from entrygraph.db.models import Edge
|
|
17
|
+
from entrygraph.kinds import EdgeKind
|
|
18
|
+
|
|
19
|
+
_MAX_DFS_VISITS = 200_000 # hard bound on path-enumeration work
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Hop:
|
|
24
|
+
dst: int
|
|
25
|
+
kind: str
|
|
26
|
+
line: int
|
|
27
|
+
confidence: int
|
|
28
|
+
edge_id: int = 0
|
|
29
|
+
via: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AdjacencyCache:
|
|
33
|
+
def __init__(self, generation: int, kinds: frozenset[str]) -> None:
|
|
34
|
+
self.generation = generation
|
|
35
|
+
self.kinds = kinds
|
|
36
|
+
self.forward: dict[int, list[Hop]] = {}
|
|
37
|
+
self.reverse: dict[int, list[Hop]] = {}
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def build(cls, session: Session, generation: int, kinds: frozenset[str],
|
|
41
|
+
min_confidence: int = 0, include_cha: bool = True) -> "AdjacencyCache":
|
|
42
|
+
cache = cls(generation, kinds)
|
|
43
|
+
conditions = [
|
|
44
|
+
Edge.kind.in_([EdgeKind(k) for k in kinds]),
|
|
45
|
+
Edge.dst_symbol_id.is_not(None),
|
|
46
|
+
Edge.confidence >= min_confidence,
|
|
47
|
+
]
|
|
48
|
+
if not include_cha: # class-hierarchy candidates are opt-in speculative edges
|
|
49
|
+
conditions.append((Edge.via.is_(None)) | (Edge.via != "cha"))
|
|
50
|
+
stmt = select(
|
|
51
|
+
Edge.src_symbol_id, Edge.dst_symbol_id, Edge.kind, Edge.line,
|
|
52
|
+
Edge.confidence, Edge.id, Edge.via,
|
|
53
|
+
).where(*conditions)
|
|
54
|
+
for src, dst, kind, line, confidence, edge_id, via in session.execute(stmt):
|
|
55
|
+
cache.forward.setdefault(src, []).append(
|
|
56
|
+
Hop(dst, kind.value, line, confidence, edge_id, via))
|
|
57
|
+
cache.reverse.setdefault(dst, []).append(
|
|
58
|
+
Hop(src, kind.value, line, confidence, edge_id, via))
|
|
59
|
+
for adjacency in (cache.forward, cache.reverse):
|
|
60
|
+
for hops in adjacency.values():
|
|
61
|
+
hops.sort(key=lambda h: (h.dst, h.line))
|
|
62
|
+
return cache
|
|
63
|
+
|
|
64
|
+
# ---------------- traversals ----------------
|
|
65
|
+
|
|
66
|
+
def neighborhood(self, starts: set[int], depth: int, direction: str) -> set[int]:
|
|
67
|
+
"""All nodes within `depth` hops (excluding the starts themselves)."""
|
|
68
|
+
adjacency = self.forward if direction == "out" else self.reverse
|
|
69
|
+
seen = set(starts)
|
|
70
|
+
frontier = set(starts)
|
|
71
|
+
found: set[int] = set()
|
|
72
|
+
for _ in range(depth):
|
|
73
|
+
next_frontier: set[int] = set()
|
|
74
|
+
for node in frontier:
|
|
75
|
+
for hop in adjacency.get(node, ()):
|
|
76
|
+
if hop.dst not in seen:
|
|
77
|
+
seen.add(hop.dst)
|
|
78
|
+
next_frontier.add(hop.dst)
|
|
79
|
+
found.add(hop.dst)
|
|
80
|
+
if not next_frontier:
|
|
81
|
+
break
|
|
82
|
+
frontier = next_frontier
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
def reachable(self, sources: set[int], sinks: set[int], max_depth: int) -> bool:
|
|
86
|
+
if sources & sinks:
|
|
87
|
+
return True
|
|
88
|
+
seen = set(sources)
|
|
89
|
+
frontier = deque((s, 0) for s in sources)
|
|
90
|
+
while frontier:
|
|
91
|
+
node, depth = frontier.popleft()
|
|
92
|
+
if depth >= max_depth:
|
|
93
|
+
continue
|
|
94
|
+
for hop in self.forward.get(node, ()):
|
|
95
|
+
if hop.dst in sinks:
|
|
96
|
+
return True
|
|
97
|
+
if hop.dst not in seen:
|
|
98
|
+
seen.add(hop.dst)
|
|
99
|
+
frontier.append((hop.dst, depth + 1))
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def paths(
|
|
103
|
+
self,
|
|
104
|
+
sources: set[int],
|
|
105
|
+
sinks: set[int],
|
|
106
|
+
max_depth: int = 25,
|
|
107
|
+
max_paths: int = 10,
|
|
108
|
+
) -> list[list[tuple[int, Hop | None]]]:
|
|
109
|
+
"""Enumerate simple paths as [(symbol_id, hop_into_it | None), ...].
|
|
110
|
+
|
|
111
|
+
DFS with an on-path visited set; neighbor order is deterministic.
|
|
112
|
+
Results are sorted shortest-first. A global visit budget bounds work on
|
|
113
|
+
pathological graphs; enumeration also stops at max_paths.
|
|
114
|
+
"""
|
|
115
|
+
results: list[list[tuple[int, Hop | None]]] = []
|
|
116
|
+
budget = _MAX_DFS_VISITS
|
|
117
|
+
|
|
118
|
+
for source in sorted(sources):
|
|
119
|
+
if budget <= 0 or len(results) >= max_paths:
|
|
120
|
+
break
|
|
121
|
+
stack: list[tuple[int, Hop | None]] = [(source, None)]
|
|
122
|
+
on_path = {source}
|
|
123
|
+
|
|
124
|
+
def dfs(node: int, depth: int) -> None:
|
|
125
|
+
nonlocal budget
|
|
126
|
+
if budget <= 0 or len(results) >= max_paths:
|
|
127
|
+
return
|
|
128
|
+
budget -= 1
|
|
129
|
+
if node in sinks:
|
|
130
|
+
results.append(list(stack))
|
|
131
|
+
return
|
|
132
|
+
if depth >= max_depth:
|
|
133
|
+
return
|
|
134
|
+
for hop in self.forward.get(node, ()):
|
|
135
|
+
if hop.dst in on_path:
|
|
136
|
+
continue
|
|
137
|
+
stack.append((hop.dst, hop))
|
|
138
|
+
on_path.add(hop.dst)
|
|
139
|
+
dfs(hop.dst, depth + 1)
|
|
140
|
+
on_path.discard(hop.dst)
|
|
141
|
+
stack.pop()
|
|
142
|
+
|
|
143
|
+
dfs(source, 0)
|
|
144
|
+
|
|
145
|
+
results.sort(key=lambda p: (len(p), [n for n, _ in p]))
|
|
146
|
+
return results[:max_paths]
|
entrygraph/graph/cte.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Recursive-CTE reachability engine — the SQL-side fallback.
|
|
2
|
+
|
|
3
|
+
Same paths()/reachable() contract as AdjacencyCache, but the traversal runs
|
|
4
|
+
inside SQLite via a recursive CTE instead of loading adjacency into memory.
|
|
5
|
+
Path reconstruction and cycle-guarding use an encoded path string, which is why
|
|
6
|
+
this is the fallback (it does more work per row); the memory engine is primary.
|
|
7
|
+
The two are kept behaviorally identical by the parametrized reachability tests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import bindparam, text
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from entrygraph.graph.adjacency import Hop
|
|
16
|
+
from entrygraph.kinds import EdgeKind
|
|
17
|
+
|
|
18
|
+
_SEP = ","
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CteEngine:
|
|
22
|
+
def __init__(self, session: Session, kinds: frozenset[str], min_confidence: int = 0,
|
|
23
|
+
include_cha: bool = True) -> None:
|
|
24
|
+
self.session = session
|
|
25
|
+
self.kind_values = [EdgeKind(k).value for k in kinds]
|
|
26
|
+
self.min_confidence = min_confidence
|
|
27
|
+
self.include_cha = include_cha
|
|
28
|
+
|
|
29
|
+
def reachable(self, sources: set[int], sinks: set[int], max_depth: int) -> bool:
|
|
30
|
+
if sources & sinks:
|
|
31
|
+
return True
|
|
32
|
+
return bool(self.paths(sources, sinks, max_depth=max_depth, max_paths=1))
|
|
33
|
+
|
|
34
|
+
def paths(
|
|
35
|
+
self, sources: set[int], sinks: set[int], max_depth: int = 25, max_paths: int = 10
|
|
36
|
+
) -> list[list[tuple[int, Hop | None]]]:
|
|
37
|
+
results: list[list[tuple[int, Hop | None]]] = []
|
|
38
|
+
# a source that is itself a sink is a length-1 path (matches memory engine)
|
|
39
|
+
for src in sorted(sources & sinks):
|
|
40
|
+
results.append([(src, None)])
|
|
41
|
+
|
|
42
|
+
rows = self.session.execute(
|
|
43
|
+
text(
|
|
44
|
+
"""
|
|
45
|
+
WITH RECURSIVE walk(node, nodes, lines, kinds, ids, confs, depth) AS (
|
|
46
|
+
SELECT e.dst_symbol_id,
|
|
47
|
+
:sep || e.src_symbol_id || :sep || e.dst_symbol_id || :sep,
|
|
48
|
+
:sep || e.line || :sep,
|
|
49
|
+
:sep || e.kind || :sep,
|
|
50
|
+
:sep || e.id || :sep,
|
|
51
|
+
:sep || e.confidence || :sep,
|
|
52
|
+
1
|
|
53
|
+
FROM edges e
|
|
54
|
+
WHERE e.src_symbol_id IN :sources
|
|
55
|
+
AND e.dst_symbol_id IS NOT NULL
|
|
56
|
+
AND e.kind IN :kinds
|
|
57
|
+
AND e.confidence >= :minconf
|
|
58
|
+
AND (:include_cha OR e.via IS NULL OR e.via != 'cha')
|
|
59
|
+
UNION ALL
|
|
60
|
+
SELECT e.dst_symbol_id,
|
|
61
|
+
w.nodes || e.dst_symbol_id || :sep,
|
|
62
|
+
w.lines || e.line || :sep,
|
|
63
|
+
w.kinds || e.kind || :sep,
|
|
64
|
+
w.ids || e.id || :sep,
|
|
65
|
+
w.confs || e.confidence || :sep,
|
|
66
|
+
w.depth + 1
|
|
67
|
+
FROM walk w
|
|
68
|
+
JOIN edges e ON e.src_symbol_id = w.node
|
|
69
|
+
WHERE w.depth < :max_depth
|
|
70
|
+
AND e.dst_symbol_id IS NOT NULL
|
|
71
|
+
AND e.kind IN :kinds
|
|
72
|
+
AND e.confidence >= :minconf
|
|
73
|
+
AND (:include_cha OR e.via IS NULL OR e.via != 'cha')
|
|
74
|
+
AND w.nodes NOT LIKE '%' || :sep || e.dst_symbol_id || :sep || '%'
|
|
75
|
+
)
|
|
76
|
+
SELECT nodes, lines, kinds, ids, confs, depth FROM walk
|
|
77
|
+
WHERE node IN :sinks
|
|
78
|
+
ORDER BY depth
|
|
79
|
+
"""
|
|
80
|
+
).bindparams(
|
|
81
|
+
bindparam("sources", expanding=True),
|
|
82
|
+
bindparam("sinks", expanding=True),
|
|
83
|
+
bindparam("kinds", expanding=True),
|
|
84
|
+
),
|
|
85
|
+
{
|
|
86
|
+
"sources": list(sources),
|
|
87
|
+
"sinks": list(sinks),
|
|
88
|
+
"kinds": self.kind_values,
|
|
89
|
+
"minconf": self.min_confidence,
|
|
90
|
+
"include_cha": 1 if self.include_cha else 0,
|
|
91
|
+
"max_depth": max_depth,
|
|
92
|
+
"sep": _SEP,
|
|
93
|
+
},
|
|
94
|
+
).all()
|
|
95
|
+
|
|
96
|
+
for nodes_str, lines_str, kinds_str, ids_str, confs_str, _depth in rows:
|
|
97
|
+
if len(results) >= max_paths:
|
|
98
|
+
break
|
|
99
|
+
path = self._decode(nodes_str, lines_str, kinds_str, ids_str, confs_str)
|
|
100
|
+
if path is not None:
|
|
101
|
+
results.append(path)
|
|
102
|
+
|
|
103
|
+
results.sort(key=lambda p: (len(p), [n for n, _ in p]))
|
|
104
|
+
return results[:max_paths]
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _decode(
|
|
108
|
+
nodes_str, lines_str, kinds_str, ids_str, confs_str
|
|
109
|
+
) -> list[tuple[int, Hop | None]] | None:
|
|
110
|
+
node_ids = [int(x) for x in nodes_str.strip(_SEP).split(_SEP) if x]
|
|
111
|
+
lines = [int(x) for x in lines_str.strip(_SEP).split(_SEP) if x]
|
|
112
|
+
kinds = [x for x in kinds_str.strip(_SEP).split(_SEP) if x]
|
|
113
|
+
edge_ids = [int(x) for x in ids_str.strip(_SEP).split(_SEP) if x]
|
|
114
|
+
confs = [int(x) for x in confs_str.strip(_SEP).split(_SEP) if x]
|
|
115
|
+
if len(lines) != len(node_ids) - 1:
|
|
116
|
+
return None
|
|
117
|
+
path: list[tuple[int, Hop | None]] = [(node_ids[0], None)]
|
|
118
|
+
for i in range(1, len(node_ids)):
|
|
119
|
+
path.append(
|
|
120
|
+
(node_ids[i], Hop(node_ids[i], kinds[i - 1], lines[i - 1],
|
|
121
|
+
confs[i - 1], edge_ids[i - 1]))
|
|
122
|
+
)
|
|
123
|
+
return path
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Query-time path risk scoring (heuristic taint tier).
|
|
2
|
+
|
|
3
|
+
Everything here runs at query time, never at index time, so weights can be
|
|
4
|
+
retuned without re-indexing. A path's risk combines: the terminal sink's
|
|
5
|
+
severity, the weakest edge confidence along the path, path length, whether any
|
|
6
|
+
hop is speculative (CHA/dynamic/callback), whether a sanitizer intervened,
|
|
7
|
+
whether the sink was called with only constant arguments, and whether the
|
|
8
|
+
source's user-controlled parameters are known.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from entrygraph.kinds import Confidence
|
|
16
|
+
|
|
17
|
+
_SEVERITY_BASE = {"critical": 1.0, "high": 0.85, "medium": 0.6, "low": 0.35}
|
|
18
|
+
_DEFAULT_SEVERITY = 0.6
|
|
19
|
+
|
|
20
|
+
_CONFIDENCE_WEIGHT = {
|
|
21
|
+
int(Confidence.EXACT): 1.0,
|
|
22
|
+
int(Confidence.IMPORT): 0.95,
|
|
23
|
+
int(Confidence.FUZZY): 0.7,
|
|
24
|
+
int(Confidence.UNRESOLVED): 0.5,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_SPECULATIVE_VIA = {"cha", "dynamic"}
|
|
28
|
+
_LENGTH_DECAY = 0.97
|
|
29
|
+
|
|
30
|
+
# A literal-only argument preview: strings, numbers, bools, None, kwarg names,
|
|
31
|
+
# and bracketed literal collections. Anything with an identifier/operator that
|
|
32
|
+
# could carry a variable makes it non-constant.
|
|
33
|
+
_CONST_TOKEN = re.compile(
|
|
34
|
+
r"""^(
|
|
35
|
+
\s | , | = | \( | \) | \[ | \] | \{ | \} | : |
|
|
36
|
+
'[^']*' | "[^"]*" | `[^`]*` |
|
|
37
|
+
\d[\d_.eExXaAbBcCdDfF]* |
|
|
38
|
+
True|False|None|null|true|false|nil |
|
|
39
|
+
[A-Za-z_]\w*\s*= # kwarg name before '='
|
|
40
|
+
)*$""",
|
|
41
|
+
re.VERBOSE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_constant_args(arg_preview: str | None) -> bool:
|
|
46
|
+
"""True if the sink was called with only literal/constant arguments.
|
|
47
|
+
|
|
48
|
+
Conservative: an empty/None preview is constant (no args); a preview that was
|
|
49
|
+
truncated at the 80-char cap (trailing ellipsis) returns False because we
|
|
50
|
+
can't see the whole argument list.
|
|
51
|
+
"""
|
|
52
|
+
if not arg_preview:
|
|
53
|
+
return True
|
|
54
|
+
text = arg_preview.strip()
|
|
55
|
+
if text.endswith("…") or text.endswith("..."):
|
|
56
|
+
return False
|
|
57
|
+
inner = text
|
|
58
|
+
if inner.startswith("(") and inner.endswith(")"):
|
|
59
|
+
inner = inner[1:-1]
|
|
60
|
+
if not inner.strip():
|
|
61
|
+
return True
|
|
62
|
+
return bool(_CONST_TOKEN.match(text))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def confidence_factor(confidences: list[int]) -> float:
|
|
66
|
+
return min((_CONFIDENCE_WEIGHT.get(c, 0.5) for c in confidences), default=1.0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def score_path(
|
|
70
|
+
*,
|
|
71
|
+
hop_confidences: list[int],
|
|
72
|
+
hop_vias: list[str | None],
|
|
73
|
+
sink_severity: str | None,
|
|
74
|
+
sanitized_effect: str | None, # "neutralizes" | "reduces" | None
|
|
75
|
+
constant_args: bool,
|
|
76
|
+
source_tainted: bool,
|
|
77
|
+
) -> float:
|
|
78
|
+
"""Return a risk score in [0, 1]; higher = riskier. See module docstring."""
|
|
79
|
+
severity_base = _SEVERITY_BASE.get(sink_severity or "", _DEFAULT_SEVERITY)
|
|
80
|
+
conf = confidence_factor(hop_confidences)
|
|
81
|
+
hops = max(len(hop_confidences), 1)
|
|
82
|
+
length_decay = _LENGTH_DECAY ** (hops - 1)
|
|
83
|
+
speculative = 0.85 if any(v in _SPECULATIVE_VIA for v in hop_vias) else 1.0
|
|
84
|
+
if sanitized_effect == "neutralizes":
|
|
85
|
+
sanitizer_factor = 0.0
|
|
86
|
+
elif sanitized_effect == "reduces":
|
|
87
|
+
sanitizer_factor = 0.3
|
|
88
|
+
else:
|
|
89
|
+
sanitizer_factor = 1.0
|
|
90
|
+
const_factor = 0.4 if constant_args else 1.0
|
|
91
|
+
source_factor = 1.0 if source_tainted else 0.9
|
|
92
|
+
risk = (
|
|
93
|
+
severity_base
|
|
94
|
+
* conf
|
|
95
|
+
* length_decay
|
|
96
|
+
* speculative
|
|
97
|
+
* sanitizer_factor
|
|
98
|
+
* const_factor
|
|
99
|
+
* source_factor
|
|
100
|
+
)
|
|
101
|
+
return round(risk, 4)
|
entrygraph/kinds.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared enums used by the ORM models, the extraction IR, and the public API.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free so worker processes can unpickle IR without importing
|
|
4
|
+
SQLAlchemy machinery.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SymbolKind(enum.Enum):
|
|
13
|
+
MODULE = "module"
|
|
14
|
+
CLASS = "class"
|
|
15
|
+
FUNCTION = "function"
|
|
16
|
+
METHOD = "method"
|
|
17
|
+
VARIABLE = "variable"
|
|
18
|
+
CONSTANT = "constant"
|
|
19
|
+
INTERFACE = "interface"
|
|
20
|
+
STRUCT = "struct"
|
|
21
|
+
PROPERTY = "property"
|
|
22
|
+
FIELD = "field"
|
|
23
|
+
EXTERNAL = "external"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EdgeKind(enum.Enum):
|
|
27
|
+
CALLS = "calls"
|
|
28
|
+
IMPORTS = "imports"
|
|
29
|
+
INHERITS = "inherits"
|
|
30
|
+
IMPLEMENTS = "implements"
|
|
31
|
+
REFERENCES = "references"
|
|
32
|
+
PASSED_AS_CALLBACK = "callback" # function name handed to another call as an argument
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EntrypointKind(enum.Enum):
|
|
36
|
+
HTTP_ROUTE = "http_route"
|
|
37
|
+
CLI_COMMAND = "cli_command"
|
|
38
|
+
MAIN = "main"
|
|
39
|
+
TASK = "task"
|
|
40
|
+
LAMBDA_HANDLER = "lambda_handler"
|
|
41
|
+
EVENT_HANDLER = "event_handler"
|
|
42
|
+
MIDDLEWARE = "middleware" # request/response interceptor (before_request, app.use, ...)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Confidence(enum.IntEnum):
|
|
46
|
+
"""How a reference was bound to its target symbol."""
|
|
47
|
+
|
|
48
|
+
UNRESOLVED = 0 # kept with its textual target only
|
|
49
|
+
FUZZY = 1 # unique-name match, no import evidence
|
|
50
|
+
IMPORT = 2 # via the file's import map (project or external)
|
|
51
|
+
EXACT = 3 # same-scope / same-module / known-class-method
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Parser construction over tree-sitter-language-pack.
|
|
2
|
+
|
|
3
|
+
We build tree_sitter.Parser objects from the pack's Language handles rather
|
|
4
|
+
than using tree_sitter_language_pack.get_parser(), whose bundled Parser class
|
|
5
|
+
is incompatible with the py-tree-sitter 0.25 API surface we use.
|
|
6
|
+
|
|
7
|
+
Parsers and Language handles are cached per process; they are created lazily
|
|
8
|
+
inside worker processes and must never be pickled.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from functools import cache
|
|
14
|
+
|
|
15
|
+
from tree_sitter import Language, Parser
|
|
16
|
+
|
|
17
|
+
# our language ids -> tree-sitter-language-pack grammar names
|
|
18
|
+
_GRAMMAR_NAMES = {
|
|
19
|
+
"python": "python",
|
|
20
|
+
"javascript": "javascript",
|
|
21
|
+
"typescript": "typescript",
|
|
22
|
+
"tsx": "tsx",
|
|
23
|
+
"go": "go",
|
|
24
|
+
"java": "java",
|
|
25
|
+
"ruby": "ruby",
|
|
26
|
+
"csharp": "csharp",
|
|
27
|
+
"php": "php",
|
|
28
|
+
"rust": "rust",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def supported(lang_id: str) -> bool:
|
|
33
|
+
return lang_id in _GRAMMAR_NAMES
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cache
|
|
37
|
+
def language(lang_id: str) -> Language:
|
|
38
|
+
from tree_sitter_language_pack import get_language
|
|
39
|
+
|
|
40
|
+
return get_language(_GRAMMAR_NAMES[lang_id])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cache
|
|
44
|
+
def parser(lang_id: str) -> Parser:
|
|
45
|
+
return Parser(language(lang_id))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse(lang_id: str, source: bytes):
|
|
49
|
+
return parser(lang_id).parse(source)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Compiled tree-sitter query cache.
|
|
2
|
+
|
|
3
|
+
The single seam between entrygraph and the py-tree-sitter query API — if the
|
|
4
|
+
API shifts again (as it did at 0.24/0.25), this is the only file to update.
|
|
5
|
+
`.scm` sources ship as package data under entrygraph/queries/<lang>/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import cache
|
|
11
|
+
from importlib.resources import files as resource_files
|
|
12
|
+
|
|
13
|
+
from tree_sitter import Node, Query, QueryCursor
|
|
14
|
+
|
|
15
|
+
from entrygraph.parsing.parsers import language
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def load_query_for(grammar_lang: str, source_lang: str, name: str) -> Query:
|
|
20
|
+
"""Compile a `.scm` source (shipped under queries/<source_lang>/) against a
|
|
21
|
+
possibly-different grammar.
|
|
22
|
+
|
|
23
|
+
A tree-sitter Query is bound to one Language and only matches trees from that
|
|
24
|
+
same grammar. TypeScript/TSX are supersets of JavaScript with the same node
|
|
25
|
+
names for the constructs we harvest, so their extractor reuses the JavaScript
|
|
26
|
+
query sources compiled against the TS/TSX grammars — without this, a `.ts`
|
|
27
|
+
tree queried with JS-compiled queries silently matches nothing.
|
|
28
|
+
"""
|
|
29
|
+
source = (resource_files("entrygraph") / "queries" / source_lang / f"{name}.scm").read_text()
|
|
30
|
+
return Query(language(grammar_lang), source)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cache
|
|
34
|
+
def load_query(lang_id: str, name: str) -> Query:
|
|
35
|
+
return load_query_for(lang_id, lang_id, name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def captures(query: Query, node: Node) -> dict[str, list[Node]]:
|
|
39
|
+
return QueryCursor(query).captures(node)
|
|
File without changes
|