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/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""entrygraph — query your codebase like a graph.
|
|
2
|
+
|
|
3
|
+
Index a repository into SQLite (via the SQLAlchemy ORM) and query symbols,
|
|
4
|
+
classes, methods, entrypoints, and source-to-sink call paths across languages.
|
|
5
|
+
|
|
6
|
+
from entrygraph import CodeGraph
|
|
7
|
+
|
|
8
|
+
graph = CodeGraph.index("/path/to/repo")
|
|
9
|
+
graph.entrypoints(framework="flask")
|
|
10
|
+
graph.paths(source="app.routes.*", sink_category="command_exec")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from entrygraph.errors import (
|
|
18
|
+
DatabaseNotFoundError,
|
|
19
|
+
EntrygraphError,
|
|
20
|
+
SchemaMismatchError,
|
|
21
|
+
SymbolNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from entrygraph.kinds import Confidence, EdgeKind, EntrypointKind, SymbolKind
|
|
24
|
+
from entrygraph.results import (
|
|
25
|
+
CallPath,
|
|
26
|
+
DetectedFramework,
|
|
27
|
+
DetectedLanguage,
|
|
28
|
+
DetectionReport,
|
|
29
|
+
Edge,
|
|
30
|
+
Entrypoint,
|
|
31
|
+
FileInfo,
|
|
32
|
+
GraphStats,
|
|
33
|
+
IndexStats,
|
|
34
|
+
PathEdge,
|
|
35
|
+
Symbol,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# written at build time by hatch-vcs (see pyproject [tool.hatch.build.hooks.vcs])
|
|
40
|
+
from entrygraph._version import __version__
|
|
41
|
+
except ImportError: # running from a raw source tree that was never built
|
|
42
|
+
try:
|
|
43
|
+
from importlib.metadata import version as _pkg_version
|
|
44
|
+
|
|
45
|
+
__version__ = _pkg_version("entrygraph")
|
|
46
|
+
except Exception: # pragma: no cover - not installed at all
|
|
47
|
+
__version__ = "0.0.0"
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
50
|
+
from entrygraph.api import CodeGraph
|
|
51
|
+
|
|
52
|
+
_LAZY = {"CodeGraph": ("entrygraph.api", "CodeGraph")}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def __getattr__(name: str) -> Any:
|
|
56
|
+
# CodeGraph pulls in the full indexing stack; keep `import entrygraph` light.
|
|
57
|
+
if name in _LAZY:
|
|
58
|
+
import importlib
|
|
59
|
+
|
|
60
|
+
module_name, attr = _LAZY[name]
|
|
61
|
+
return getattr(importlib.import_module(module_name), attr)
|
|
62
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"CallPath",
|
|
67
|
+
"CodeGraph",
|
|
68
|
+
"Confidence",
|
|
69
|
+
"DatabaseNotFoundError",
|
|
70
|
+
"DetectedFramework",
|
|
71
|
+
"DetectedLanguage",
|
|
72
|
+
"DetectionReport",
|
|
73
|
+
"Edge",
|
|
74
|
+
"EdgeKind",
|
|
75
|
+
"Entrypoint",
|
|
76
|
+
"EntrypointKind",
|
|
77
|
+
"EntrygraphError",
|
|
78
|
+
"FileInfo",
|
|
79
|
+
"GraphStats",
|
|
80
|
+
"IndexStats",
|
|
81
|
+
"PathEdge",
|
|
82
|
+
"SchemaMismatchError",
|
|
83
|
+
"Symbol",
|
|
84
|
+
"SymbolKind",
|
|
85
|
+
"SymbolNotFoundError",
|
|
86
|
+
"__version__",
|
|
87
|
+
]
|
entrygraph/__main__.py
ADDED
entrygraph/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
entrygraph/api.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""CodeGraph — the public facade.
|
|
2
|
+
|
|
3
|
+
Every method opens a short-lived ORM Session internally and returns detached,
|
|
4
|
+
frozen dataclasses; the Session never escapes (except via the explicit
|
|
5
|
+
``session()`` escape hatch).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import Engine, func, select, text
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from entrygraph.db import models
|
|
18
|
+
from entrygraph.db import queries as q
|
|
19
|
+
from entrygraph.db.engine import make_engine, make_session_factory
|
|
20
|
+
from entrygraph.db.meta import check_schema
|
|
21
|
+
from entrygraph.errors import (
|
|
22
|
+
DatabaseNotFoundError,
|
|
23
|
+
RepositoryNotIndexedError,
|
|
24
|
+
SymbolNotFoundError,
|
|
25
|
+
)
|
|
26
|
+
from entrygraph.graph.adjacency import AdjacencyCache, Hop
|
|
27
|
+
from entrygraph.graph.scoring import is_constant_args, score_path
|
|
28
|
+
from entrygraph.kinds import Confidence
|
|
29
|
+
from entrygraph.results import (
|
|
30
|
+
CallPath,
|
|
31
|
+
DetectedFramework,
|
|
32
|
+
DetectedLanguage,
|
|
33
|
+
DetectionReport,
|
|
34
|
+
Edge,
|
|
35
|
+
Entrypoint,
|
|
36
|
+
FileInfo,
|
|
37
|
+
GraphStats,
|
|
38
|
+
IndexStats,
|
|
39
|
+
PathEdge,
|
|
40
|
+
Symbol,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
DEFAULT_DB_NAME = ".entrygraph.db"
|
|
44
|
+
|
|
45
|
+
SourceSpec = "str | Symbol | Entrypoint | list[str | Symbol | Entrypoint]"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _traversal_params(
|
|
49
|
+
min_confidence: int | None, include_fuzzy: bool, include_unresolved: bool
|
|
50
|
+
) -> tuple[int, bool]:
|
|
51
|
+
"""Derive (confidence floor, include_cha) for traversal.
|
|
52
|
+
|
|
53
|
+
Default floor is FUZZY: EXACT/IMPORT/unique-name-FUZZY edges are traversed,
|
|
54
|
+
which keeps ordinary method dispatch on a local variable reachable. The
|
|
55
|
+
speculative class-hierarchy fan-out (via="cha") stays hidden until
|
|
56
|
+
`include_fuzzy=True`. `include_unresolved=True` lowers the floor to 0, which
|
|
57
|
+
admits UNRESOLVED wildcard-sink guesses (`py:*.execute`) and dynamic-call
|
|
58
|
+
placeholders. An explicit `min_confidence` int overrides the floor.
|
|
59
|
+
"""
|
|
60
|
+
if min_confidence is not None:
|
|
61
|
+
floor = min_confidence
|
|
62
|
+
elif include_unresolved:
|
|
63
|
+
floor = int(Confidence.UNRESOLVED)
|
|
64
|
+
else:
|
|
65
|
+
floor = int(Confidence.FUZZY)
|
|
66
|
+
return floor, include_fuzzy
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fully_sanitized(path: CallPath) -> bool:
|
|
70
|
+
"""True if the terminal hop was neutralized by a sanitizer (risk driven to 0)."""
|
|
71
|
+
return path.risk_score == 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CodeGraph:
|
|
75
|
+
def __init__(self, engine: Engine) -> None:
|
|
76
|
+
self._engine = engine
|
|
77
|
+
self._session_factory = make_session_factory(engine)
|
|
78
|
+
self._adjacency: dict[tuple[frozenset[str], int], AdjacencyCache] = {}
|
|
79
|
+
|
|
80
|
+
# ---------------- construction ----------------
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def index(cls, root: str | Path, db: str | Path | None = None) -> "CodeGraph":
|
|
84
|
+
"""Index (or fully re-index) a repository and return an open graph."""
|
|
85
|
+
from entrygraph.pipeline.scanner import index_repository
|
|
86
|
+
|
|
87
|
+
root = Path(root).resolve()
|
|
88
|
+
db_path = Path(db) if db else root / DEFAULT_DB_NAME
|
|
89
|
+
engine = make_engine(db_path)
|
|
90
|
+
graph = cls(engine)
|
|
91
|
+
graph._last_index_stats = index_repository(root, engine)
|
|
92
|
+
return graph
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def open(cls, db: str | Path) -> "CodeGraph":
|
|
96
|
+
db_path = Path(db)
|
|
97
|
+
if not db_path.exists():
|
|
98
|
+
raise DatabaseNotFoundError(f"no index database at {db_path}")
|
|
99
|
+
engine = make_engine(db_path)
|
|
100
|
+
check_schema(engine)
|
|
101
|
+
return cls(engine)
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
self._engine.dispose()
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> "CodeGraph":
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, *exc) -> None:
|
|
110
|
+
self.close()
|
|
111
|
+
|
|
112
|
+
# ---------------- symbols / files ----------------
|
|
113
|
+
|
|
114
|
+
def symbols(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
kind: str | None = None,
|
|
118
|
+
name: str | None = None,
|
|
119
|
+
qname: str | None = None,
|
|
120
|
+
file: str | None = None,
|
|
121
|
+
include_external: bool = False,
|
|
122
|
+
limit: int | None = None,
|
|
123
|
+
offset: int | None = None,
|
|
124
|
+
) -> list[Symbol]:
|
|
125
|
+
with self._session_factory() as session:
|
|
126
|
+
return q.select_symbols(
|
|
127
|
+
session, kind=kind, name=name, qname=qname, file=file,
|
|
128
|
+
include_external=include_external, limit=limit, offset=offset,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def symbol(self, qname: str) -> Symbol:
|
|
132
|
+
matches = self.symbols(qname=qname, include_external=True, limit=2)
|
|
133
|
+
exact = [s for s in matches if s.qname == qname]
|
|
134
|
+
if not exact:
|
|
135
|
+
raise SymbolNotFoundError(f"no symbol with qname {qname!r}")
|
|
136
|
+
return exact[0]
|
|
137
|
+
|
|
138
|
+
def iter_symbols(self, *, batch_size: int = 1000, **filters) -> Iterator[Symbol]:
|
|
139
|
+
offset = 0
|
|
140
|
+
while True:
|
|
141
|
+
batch = self.symbols(**filters, limit=batch_size, offset=offset)
|
|
142
|
+
yield from batch
|
|
143
|
+
if len(batch) < batch_size:
|
|
144
|
+
return
|
|
145
|
+
offset += batch_size
|
|
146
|
+
|
|
147
|
+
def files(self, *, language: str | None = None, path: str | None = None) -> list[FileInfo]:
|
|
148
|
+
with self._session_factory() as session:
|
|
149
|
+
return q.select_files(session, language=language, path=path)
|
|
150
|
+
|
|
151
|
+
# ---------------- detection ----------------
|
|
152
|
+
|
|
153
|
+
def detect(self) -> DetectionReport:
|
|
154
|
+
with self._session_factory() as session:
|
|
155
|
+
rows = session.execute(
|
|
156
|
+
select(models.Detection).order_by(models.Detection.confidence.desc())
|
|
157
|
+
).scalars().all()
|
|
158
|
+
languages = []
|
|
159
|
+
frameworks = []
|
|
160
|
+
for row in rows:
|
|
161
|
+
evidence = json.loads(row.evidence) if row.evidence else {}
|
|
162
|
+
if row.category == "language":
|
|
163
|
+
languages.append(
|
|
164
|
+
DetectedLanguage(
|
|
165
|
+
name=row.name,
|
|
166
|
+
file_count=evidence.get("files", 0),
|
|
167
|
+
byte_count=evidence.get("bytes", 0),
|
|
168
|
+
percent=evidence.get("percent", 0.0),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
frameworks.append(
|
|
173
|
+
DetectedFramework(
|
|
174
|
+
name=row.name,
|
|
175
|
+
language=evidence.get("language", ""),
|
|
176
|
+
confidence=row.confidence,
|
|
177
|
+
evidence=tuple(evidence.get("signals", [])),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
languages.sort(key=lambda l: l.byte_count, reverse=True)
|
|
181
|
+
return DetectionReport(languages=tuple(languages), frameworks=tuple(frameworks))
|
|
182
|
+
|
|
183
|
+
# ---------------- entrypoints ----------------
|
|
184
|
+
|
|
185
|
+
def entrypoints(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
kind: str | None = None,
|
|
189
|
+
framework: str | None = None,
|
|
190
|
+
route: str | None = None,
|
|
191
|
+
limit: int | None = None,
|
|
192
|
+
) -> list[Entrypoint]:
|
|
193
|
+
with self._session_factory() as session:
|
|
194
|
+
return q.select_entrypoints(
|
|
195
|
+
session, kind=kind, framework=framework, route=route, limit=limit
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# ---------------- traversal ----------------
|
|
199
|
+
|
|
200
|
+
def callers(self, target: "SourceSpec", *, depth: int = 1,
|
|
201
|
+
edge_kinds: tuple[str, ...] = ("calls",)) -> list[Symbol]:
|
|
202
|
+
return self._neighbors(target, depth, "in", edge_kinds)
|
|
203
|
+
|
|
204
|
+
def callees(self, target: "SourceSpec", *, depth: int = 1,
|
|
205
|
+
edge_kinds: tuple[str, ...] = ("calls",)) -> list[Symbol]:
|
|
206
|
+
return self._neighbors(target, depth, "out", edge_kinds)
|
|
207
|
+
|
|
208
|
+
def references(self, target: "SourceSpec") -> list[Edge]:
|
|
209
|
+
"""All inbound edges (any kind) to the matching symbols."""
|
|
210
|
+
with self._session_factory() as session:
|
|
211
|
+
ids = self._spec_to_ids(session, target)
|
|
212
|
+
if not ids:
|
|
213
|
+
return []
|
|
214
|
+
rows = session.execute(
|
|
215
|
+
select(models.Edge).where(models.Edge.dst_symbol_id.in_(ids))
|
|
216
|
+
).scalars().all()
|
|
217
|
+
src_map = q.symbols_by_ids(session, {r.src_symbol_id for r in rows})
|
|
218
|
+
return [
|
|
219
|
+
Edge(
|
|
220
|
+
id=r.id,
|
|
221
|
+
kind=r.kind.value,
|
|
222
|
+
src_qname=src_map[r.src_symbol_id].qname if r.src_symbol_id in src_map else "?",
|
|
223
|
+
dst_qname=r.dst_qname,
|
|
224
|
+
resolved=r.dst_symbol_id is not None,
|
|
225
|
+
line=r.line,
|
|
226
|
+
confidence=r.confidence,
|
|
227
|
+
file=src_map[r.src_symbol_id].file if r.src_symbol_id in src_map else None,
|
|
228
|
+
sink_id=r.sink_id,
|
|
229
|
+
arg_preview=r.arg_preview,
|
|
230
|
+
)
|
|
231
|
+
for r in rows
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
# ---------------- reachability ----------------
|
|
235
|
+
|
|
236
|
+
def paths(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
source: "SourceSpec",
|
|
240
|
+
sink: "SourceSpec | None" = None,
|
|
241
|
+
sink_category: str | None = None,
|
|
242
|
+
max_depth: int = 25,
|
|
243
|
+
max_paths: int = 10,
|
|
244
|
+
edge_kinds: tuple[str, ...] = ("calls",),
|
|
245
|
+
min_confidence: int | None = None,
|
|
246
|
+
include_fuzzy: bool = False,
|
|
247
|
+
include_unresolved: bool = False,
|
|
248
|
+
include_callbacks: bool = False,
|
|
249
|
+
prune_sanitized: bool = False,
|
|
250
|
+
engine: str = "memory",
|
|
251
|
+
) -> list[CallPath]:
|
|
252
|
+
"""Enumerate source->sink call paths, risk-ranked (highest first).
|
|
253
|
+
|
|
254
|
+
By default only EXACT/IMPORT edges are traversed; `include_fuzzy` /
|
|
255
|
+
`include_unresolved` lower the confidence floor (an explicit
|
|
256
|
+
`min_confidence` int overrides the flags). `include_callbacks` also
|
|
257
|
+
follows PASSED_AS_CALLBACK edges. Each returned path carries a heuristic
|
|
258
|
+
`risk_score` and a `may_continue` flag; `prune_sanitized` drops paths
|
|
259
|
+
neutralized by a registered sanitizer.
|
|
260
|
+
"""
|
|
261
|
+
floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
|
|
262
|
+
kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
|
|
263
|
+
with self._session_factory() as session:
|
|
264
|
+
sources = self._spec_to_ids(session, source)
|
|
265
|
+
sinks = self._sink_ids(session, sink, sink_category)
|
|
266
|
+
if not sources or not sinks:
|
|
267
|
+
return []
|
|
268
|
+
traverser = self._traverser(session, engine, kinds, floor, include_cha)
|
|
269
|
+
raw_paths = traverser.paths(sources, sinks, max_depth=max_depth, max_paths=max_paths)
|
|
270
|
+
if not raw_paths:
|
|
271
|
+
return []
|
|
272
|
+
all_ids = {node for path in raw_paths for node, _ in path}
|
|
273
|
+
symbol_map = q.symbols_by_ids(session, all_ids)
|
|
274
|
+
registry = self._registry(session)
|
|
275
|
+
edge_map = self._edge_rows(session, raw_paths)
|
|
276
|
+
tainted_sources = self._tainted_source_ids(session, sources)
|
|
277
|
+
excluded_nodes = self._nodes_with_open_frontier(session, all_ids, kinds, floor)
|
|
278
|
+
built = [
|
|
279
|
+
self._materialize_path(
|
|
280
|
+
path, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
|
|
281
|
+
)
|
|
282
|
+
for path in raw_paths
|
|
283
|
+
]
|
|
284
|
+
results = [cp for cp in built if not (prune_sanitized and _fully_sanitized(cp))]
|
|
285
|
+
results.sort(key=lambda p: (-(p.risk_score or 0.0), len(p.symbols),
|
|
286
|
+
[s.id for s in p.symbols]))
|
|
287
|
+
return results
|
|
288
|
+
|
|
289
|
+
def reachable(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
source: "SourceSpec",
|
|
293
|
+
sink: "SourceSpec | None" = None,
|
|
294
|
+
sink_category: str | None = None,
|
|
295
|
+
max_depth: int = 25,
|
|
296
|
+
edge_kinds: tuple[str, ...] = ("calls",),
|
|
297
|
+
min_confidence: int | None = None,
|
|
298
|
+
include_fuzzy: bool = False,
|
|
299
|
+
include_unresolved: bool = False,
|
|
300
|
+
include_callbacks: bool = False,
|
|
301
|
+
engine: str = "memory",
|
|
302
|
+
) -> bool:
|
|
303
|
+
floor, include_cha = _traversal_params(min_confidence, include_fuzzy, include_unresolved)
|
|
304
|
+
kinds = (*edge_kinds, "callback") if include_callbacks else edge_kinds
|
|
305
|
+
with self._session_factory() as session:
|
|
306
|
+
sources = self._spec_to_ids(session, source)
|
|
307
|
+
sinks = self._sink_ids(session, sink, sink_category)
|
|
308
|
+
if not sources or not sinks:
|
|
309
|
+
return False
|
|
310
|
+
traverser = self._traverser(session, engine, kinds, floor, include_cha)
|
|
311
|
+
return traverser.reachable(sources, sinks, max_depth)
|
|
312
|
+
|
|
313
|
+
# ---------------- maintenance ----------------
|
|
314
|
+
|
|
315
|
+
def refresh(self, *, paranoid: bool = False) -> IndexStats:
|
|
316
|
+
"""Incrementally re-index: only changed/added/deleted files are reparsed."""
|
|
317
|
+
from entrygraph.pipeline.scanner import index_repository
|
|
318
|
+
|
|
319
|
+
with self._session_factory() as session:
|
|
320
|
+
repo = session.execute(select(models.Repository)).scalars().first()
|
|
321
|
+
if repo is None:
|
|
322
|
+
raise RepositoryNotIndexedError("database has no indexed repository")
|
|
323
|
+
stats = index_repository(repo.root_path, self._engine, incremental=True,
|
|
324
|
+
paranoid=paranoid)
|
|
325
|
+
self._adjacency.clear()
|
|
326
|
+
return stats
|
|
327
|
+
|
|
328
|
+
def stats(self) -> GraphStats:
|
|
329
|
+
with self._session_factory() as session:
|
|
330
|
+
repo = session.execute(select(models.Repository)).scalars().first()
|
|
331
|
+
if repo is None:
|
|
332
|
+
raise RepositoryNotIndexedError("database has no indexed repository")
|
|
333
|
+
|
|
334
|
+
def count(stmt) -> int:
|
|
335
|
+
return session.execute(stmt).scalar() or 0
|
|
336
|
+
|
|
337
|
+
return GraphStats(
|
|
338
|
+
repo_root=repo.root_path,
|
|
339
|
+
index_generation=repo.index_generation,
|
|
340
|
+
files=count(select(func.count(models.File.id))),
|
|
341
|
+
symbols=count(select(func.count(models.Symbol.id))),
|
|
342
|
+
edges=count(select(func.count(models.Edge.id))),
|
|
343
|
+
resolved_edges=count(
|
|
344
|
+
select(func.count(models.Edge.id)).where(
|
|
345
|
+
models.Edge.dst_symbol_id.is_not(None)
|
|
346
|
+
)
|
|
347
|
+
),
|
|
348
|
+
entrypoints=count(select(func.count(models.Entrypoint.id))),
|
|
349
|
+
sink_edges=count(
|
|
350
|
+
select(func.count(models.Edge.id)).where(models.Edge.sink_id.is_not(None))
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def session(self) -> Session:
|
|
355
|
+
"""Raw ORM session — the escape hatch. Caller owns the lifecycle."""
|
|
356
|
+
return self._session_factory()
|
|
357
|
+
|
|
358
|
+
def sql(self, statement: str, params: dict | None = None) -> list[dict]:
|
|
359
|
+
with self._session_factory() as session:
|
|
360
|
+
result = session.execute(text(statement), params or {})
|
|
361
|
+
return [dict(row._mapping) for row in result]
|
|
362
|
+
|
|
363
|
+
# ---------------- internals ----------------
|
|
364
|
+
|
|
365
|
+
def _generation(self, session: Session) -> int:
|
|
366
|
+
gen = session.execute(select(models.Repository.index_generation)).scalar()
|
|
367
|
+
return gen or 0
|
|
368
|
+
|
|
369
|
+
def _traverser(self, session: Session, engine: str, edge_kinds: tuple[str, ...],
|
|
370
|
+
min_confidence: int, include_cha: bool = True):
|
|
371
|
+
if engine == "sql":
|
|
372
|
+
from entrygraph.graph.cte import CteEngine
|
|
373
|
+
|
|
374
|
+
return CteEngine(session, frozenset(edge_kinds), min_confidence, include_cha)
|
|
375
|
+
if engine != "memory":
|
|
376
|
+
raise ValueError(f"unknown reachability engine {engine!r} (use 'memory' or 'sql')")
|
|
377
|
+
return self._cache(session, edge_kinds, min_confidence, include_cha)
|
|
378
|
+
|
|
379
|
+
def _cache(self, session: Session, edge_kinds: tuple[str, ...],
|
|
380
|
+
min_confidence: int, include_cha: bool = True) -> AdjacencyCache:
|
|
381
|
+
kinds = frozenset(edge_kinds)
|
|
382
|
+
generation = self._generation(session)
|
|
383
|
+
key = (kinds | {f"minconf:{min_confidence}", f"cha:{include_cha}"}, generation)
|
|
384
|
+
cache = self._adjacency.get(key)
|
|
385
|
+
if cache is None:
|
|
386
|
+
cache = AdjacencyCache.build(session, generation, kinds, min_confidence, include_cha)
|
|
387
|
+
self._adjacency = {k: v for k, v in self._adjacency.items() if k[1] == generation}
|
|
388
|
+
self._adjacency[key] = cache
|
|
389
|
+
return cache
|
|
390
|
+
|
|
391
|
+
def _spec_to_ids(self, session: Session, spec) -> set[int]:
|
|
392
|
+
if spec is None:
|
|
393
|
+
return set()
|
|
394
|
+
if isinstance(spec, (list, tuple, set)):
|
|
395
|
+
ids: set[int] = set()
|
|
396
|
+
for item in spec:
|
|
397
|
+
ids |= self._spec_to_ids(session, item)
|
|
398
|
+
return ids
|
|
399
|
+
if isinstance(spec, Symbol):
|
|
400
|
+
return {spec.id}
|
|
401
|
+
if isinstance(spec, Entrypoint):
|
|
402
|
+
return {spec.symbol.id}
|
|
403
|
+
return q.symbol_ids_matching(session, str(spec))
|
|
404
|
+
|
|
405
|
+
def _sink_ids(self, session: Session, sink, sink_category: str | None) -> set[int]:
|
|
406
|
+
ids = self._spec_to_ids(session, sink) if sink is not None else set()
|
|
407
|
+
if sink_category is not None:
|
|
408
|
+
from entrygraph.detect.taint import registry_for_repo
|
|
409
|
+
|
|
410
|
+
repo = session.execute(select(models.Repository)).scalars().first()
|
|
411
|
+
registry = registry_for_repo(repo.root_path if repo else None)
|
|
412
|
+
sink_ids = registry.ids_for_category(sink_category)
|
|
413
|
+
if sink_ids:
|
|
414
|
+
rows = session.execute(
|
|
415
|
+
select(models.Edge.dst_symbol_id).where(
|
|
416
|
+
models.Edge.sink_id.in_(sink_ids),
|
|
417
|
+
models.Edge.dst_symbol_id.is_not(None),
|
|
418
|
+
)
|
|
419
|
+
).scalars()
|
|
420
|
+
ids |= set(rows)
|
|
421
|
+
return ids
|
|
422
|
+
|
|
423
|
+
def _neighbors(self, target, depth: int, direction: str,
|
|
424
|
+
edge_kinds: tuple[str, ...]) -> list[Symbol]:
|
|
425
|
+
with self._session_factory() as session:
|
|
426
|
+
ids = self._spec_to_ids(session, target)
|
|
427
|
+
if not ids:
|
|
428
|
+
raise SymbolNotFoundError(f"no symbol matching {target!r}")
|
|
429
|
+
cache = self._cache(session, edge_kinds, 0)
|
|
430
|
+
found = cache.neighborhood(ids, depth, direction)
|
|
431
|
+
symbol_map = q.symbols_by_ids(session, found)
|
|
432
|
+
return sorted(symbol_map.values(), key=lambda s: s.qname)
|
|
433
|
+
|
|
434
|
+
def _registry(self, session: Session):
|
|
435
|
+
from entrygraph.detect.taint import registry_for_repo
|
|
436
|
+
|
|
437
|
+
repo = session.execute(select(models.Repository)).scalars().first()
|
|
438
|
+
return registry_for_repo(repo.root_path if repo else None)
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _edge_rows(session: Session, raw_paths) -> dict[int, "models.Edge"]:
|
|
442
|
+
edge_ids = {hop.edge_id for path in raw_paths for _, hop in path if hop and hop.edge_id}
|
|
443
|
+
if not edge_ids:
|
|
444
|
+
return {}
|
|
445
|
+
rows = session.execute(
|
|
446
|
+
select(models.Edge).where(models.Edge.id.in_(edge_ids))
|
|
447
|
+
).scalars().all()
|
|
448
|
+
return {r.id: r for r in rows}
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _tainted_source_ids(session: Session, sources: set[int]) -> set[int]:
|
|
452
|
+
"""Source symbol ids that are entrypoints with known user-controlled params."""
|
|
453
|
+
if not sources:
|
|
454
|
+
return set()
|
|
455
|
+
rows = session.execute(
|
|
456
|
+
select(models.Entrypoint.symbol_id, models.Entrypoint.extra).where(
|
|
457
|
+
models.Entrypoint.symbol_id.in_(sources)
|
|
458
|
+
)
|
|
459
|
+
).all()
|
|
460
|
+
tainted: set[int] = set()
|
|
461
|
+
for sid, extra in rows:
|
|
462
|
+
if extra and json.loads(extra).get("tainted_params"):
|
|
463
|
+
tainted.add(sid)
|
|
464
|
+
return tainted
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _nodes_with_open_frontier(
|
|
468
|
+
session: Session, node_ids: set[int], kinds: tuple[str, ...], floor: int
|
|
469
|
+
) -> set[int]:
|
|
470
|
+
"""Path nodes that have outgoing call edges the confidence filter excluded
|
|
471
|
+
(or dynamic placeholders) — i.e. reachability may continue past them."""
|
|
472
|
+
if not node_ids:
|
|
473
|
+
return set()
|
|
474
|
+
from entrygraph.kinds import EdgeKind
|
|
475
|
+
|
|
476
|
+
kind_enums = [EdgeKind(k) for k in kinds if k in {e.value for e in EdgeKind}]
|
|
477
|
+
rows = session.execute(
|
|
478
|
+
select(models.Edge.src_symbol_id).where(
|
|
479
|
+
models.Edge.src_symbol_id.in_(node_ids),
|
|
480
|
+
models.Edge.dst_symbol_id.is_not(None),
|
|
481
|
+
models.Edge.kind.in_(kind_enums),
|
|
482
|
+
(models.Edge.confidence < floor) | (models.Edge.via == "dynamic"),
|
|
483
|
+
)
|
|
484
|
+
).scalars()
|
|
485
|
+
return set(rows)
|
|
486
|
+
|
|
487
|
+
def _materialize_path(
|
|
488
|
+
self, path, symbol_map, edge_map, registry, tainted_sources, excluded_nodes
|
|
489
|
+
) -> CallPath:
|
|
490
|
+
symbols = tuple(symbol_map[node] for node, _ in path)
|
|
491
|
+
hops = [hop for _, hop in path[1:]]
|
|
492
|
+
rows = [edge_map.get(hop.edge_id) if hop else None for hop in hops]
|
|
493
|
+
|
|
494
|
+
# sink is the terminal edge; its category drives sanitizer matching
|
|
495
|
+
terminal = rows[-1] if rows else None
|
|
496
|
+
sink_id = terminal.sink_id if terminal else None
|
|
497
|
+
sink_category = registry.sinks[sink_id].category if sink_id in registry.sinks else None
|
|
498
|
+
terminal_const = is_constant_args(terminal.arg_preview) if terminal else False
|
|
499
|
+
|
|
500
|
+
# sanitizer detection: any on-path symbol OR sibling call matching a
|
|
501
|
+
# sanitizer for the sink's category
|
|
502
|
+
sanitized_ids, sanitized_effect = self._path_sanitizers(
|
|
503
|
+
symbols, rows, registry, sink_category
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
path_edges: list[PathEdge] = []
|
|
507
|
+
for i, hop in enumerate(hops):
|
|
508
|
+
row = rows[i]
|
|
509
|
+
is_terminal = i == len(hops) - 1
|
|
510
|
+
path_edges.append(
|
|
511
|
+
PathEdge(
|
|
512
|
+
kind=hop.kind,
|
|
513
|
+
line=hop.line,
|
|
514
|
+
confidence=hop.confidence,
|
|
515
|
+
sink_id=row.sink_id if row else None,
|
|
516
|
+
via=row.via if row else hop.via,
|
|
517
|
+
arg_preview=row.arg_preview if row else None,
|
|
518
|
+
constant_args=terminal_const if is_terminal else False,
|
|
519
|
+
sanitized_by=tuple(sanitized_ids) if is_terminal else (),
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
risk = score_path(
|
|
524
|
+
hop_confidences=[h.confidence for h in hops],
|
|
525
|
+
hop_vias=[(rows[i].via if rows[i] else h.via) for i, h in enumerate(hops)],
|
|
526
|
+
sink_severity=registry.severity_of(sink_id),
|
|
527
|
+
sanitized_effect=sanitized_effect,
|
|
528
|
+
constant_args=terminal_const,
|
|
529
|
+
source_tainted=path[0][0] in tainted_sources,
|
|
530
|
+
)
|
|
531
|
+
may_continue = any(node in excluded_nodes for node, _ in path)
|
|
532
|
+
return CallPath(symbols=symbols, edges=tuple(path_edges),
|
|
533
|
+
risk_score=risk, may_continue=may_continue)
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _path_sanitizers(symbols, rows, registry, sink_category):
|
|
537
|
+
"""Return (matched sanitizer ids, strongest effect) for the sink category."""
|
|
538
|
+
if not sink_category:
|
|
539
|
+
return [], None
|
|
540
|
+
matched: list = []
|
|
541
|
+
candidates = [s.qname for s in symbols] + [r.dst_qname for r in rows if r is not None]
|
|
542
|
+
for qname in candidates:
|
|
543
|
+
for san in registry.match_sanitizers(qname):
|
|
544
|
+
if san.category == sink_category:
|
|
545
|
+
matched.append(san)
|
|
546
|
+
if not matched:
|
|
547
|
+
return [], None
|
|
548
|
+
effect = "neutralizes" if any(s.effect == "neutralizes" for s in matched) else "reduces"
|
|
549
|
+
return [s.id for s in matched], effect
|
|
File without changes
|