coretrace-python-analyzer 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.
- coretrace_python/__init__.py +4 -0
- coretrace_python/__main__.py +4 -0
- coretrace_python/abstract/__init__.py +45 -0
- coretrace_python/abstract/constants.py +226 -0
- coretrace_python/abstract/heap.py +252 -0
- coretrace_python/abstract/ranges.py +285 -0
- coretrace_python/abstract/values.py +56 -0
- coretrace_python/analysis/__init__.py +31 -0
- coretrace_python/analysis/manager.py +165 -0
- coretrace_python/analysis/provider.py +73 -0
- coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
- coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
- coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
- coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
- coretrace_python/bundled/models/cli/cli_models.py +35 -0
- coretrace_python/bundled/models/cli/plugin.toml +9 -0
- coretrace_python/bundled/models/credentials/credential_models.py +43 -0
- coretrace_python/bundled/models/credentials/plugin.toml +9 -0
- coretrace_python/bundled/models/django/django_models.py +123 -0
- coretrace_python/bundled/models/django/plugin.toml +9 -0
- coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
- coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
- coretrace_python/bundled/models/flask/flask_models.py +53 -0
- coretrace_python/bundled/models/flask/plugin.toml +9 -0
- coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
- coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
- coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
- coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
- coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
- coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
- coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
- coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
- coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
- coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
- coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
- coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
- coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
- coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
- coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
- coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
- coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
- coretrace_python/bundled/security/xss/plugin.toml +9 -0
- coretrace_python/bundled/security/xss/xss.py +17 -0
- coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
- coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
- coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
- coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
- coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
- coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
- coretrace_python/cache.py +310 -0
- coretrace_python/cfg/__init__.py +46 -0
- coretrace_python/cfg/builder.py +589 -0
- coretrace_python/cfg/dominance.py +183 -0
- coretrace_python/cfg/model.py +166 -0
- coretrace_python/cli.py +216 -0
- coretrace_python/dataflow/__init__.py +29 -0
- coretrace_python/dataflow/lattice.py +78 -0
- coretrace_python/dataflow/solver.py +96 -0
- coretrace_python/dependency/__init__.py +44 -0
- coretrace_python/dependency/advisories.py +168 -0
- coretrace_python/dependency/correlation.py +87 -0
- coretrace_python/dependency/graph.py +274 -0
- coretrace_python/dependency/policy.py +65 -0
- coretrace_python/dependency/sbom.py +65 -0
- coretrace_python/engine.py +688 -0
- coretrace_python/findings/__init__.py +13 -0
- coretrace_python/findings/coverage.py +45 -0
- coretrace_python/findings/model.py +49 -0
- coretrace_python/findings/refutation.py +427 -0
- coretrace_python/frontend/__init__.py +18 -0
- coretrace_python/frontend/ast_adapter.py +447 -0
- coretrace_python/frontend/parser.py +23 -0
- coretrace_python/hir/__init__.py +5 -0
- coretrace_python/hir/nodes.py +584 -0
- coretrace_python/hir/visitors.py +36 -0
- coretrace_python/interprocedural/__init__.py +49 -0
- coretrace_python/interprocedural/callgraph.py +291 -0
- coretrace_python/interprocedural/modulegraph.py +218 -0
- coretrace_python/interprocedural/summaries.py +463 -0
- coretrace_python/ir/__init__.py +5 -0
- coretrace_python/ir/defuse.py +74 -0
- coretrace_python/ir/lowering.py +575 -0
- coretrace_python/ir/model.py +481 -0
- coretrace_python/ir/printer.py +201 -0
- coretrace_python/ir/ssa.py +277 -0
- coretrace_python/plugins/__init__.py +58 -0
- coretrace_python/plugins/api.py +153 -0
- coretrace_python/plugins/detectors.py +114 -0
- coretrace_python/plugins/loader.py +84 -0
- coretrace_python/plugins/manifest.py +112 -0
- coretrace_python/plugins/registry.py +34 -0
- coretrace_python/plugins/secrets.py +330 -0
- coretrace_python/reporters/__init__.py +22 -0
- coretrace_python/reporters/json_format.py +48 -0
- coretrace_python/reporters/report.py +23 -0
- coretrace_python/reporters/sarif.py +70 -0
- coretrace_python/reporters/text.py +24 -0
- coretrace_python/semantic/__init__.py +9 -0
- coretrace_python/semantic/identity.py +39 -0
- coretrace_python/semantic/imports.py +131 -0
- coretrace_python/semantic/scopes.py +473 -0
- coretrace_python/semantic/symbols.py +87 -0
- coretrace_python/source/__init__.py +13 -0
- coretrace_python/source/manager.py +81 -0
- coretrace_python/source/model.py +57 -0
- coretrace_python/taint/__init__.py +55 -0
- coretrace_python/taint/engine.py +800 -0
- coretrace_python/taint/models.py +317 -0
- coretrace_python/taint/routes.py +99 -0
- coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
- coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
- coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
- coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"""Composition root: the full analysis DAG, plugin loading and one check run.
|
|
2
|
+
|
|
3
|
+
This is the only module that knows every layer. The CLI and future hosts call it;
|
|
4
|
+
plugins and analyses never import it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import multiprocessing
|
|
11
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import Any, ClassVar
|
|
16
|
+
|
|
17
|
+
from coretrace_python import __version__
|
|
18
|
+
from coretrace_python.abstract import ConstantPropagation, HeapAnalysis, RangeAnalysis
|
|
19
|
+
from coretrace_python.analysis import (
|
|
20
|
+
AnalysisContext,
|
|
21
|
+
AnalysisManager,
|
|
22
|
+
AnyAnalysis,
|
|
23
|
+
TransformationPass,
|
|
24
|
+
)
|
|
25
|
+
from coretrace_python.cache import (
|
|
26
|
+
CachedModule,
|
|
27
|
+
ProjectCache,
|
|
28
|
+
decode,
|
|
29
|
+
decode_index,
|
|
30
|
+
directory_fingerprint,
|
|
31
|
+
encode,
|
|
32
|
+
encode_index,
|
|
33
|
+
fingerprint,
|
|
34
|
+
module_keys,
|
|
35
|
+
)
|
|
36
|
+
from coretrace_python.cfg import CFGAnalysis, CFGError, DominanceAnalysis, PostDominanceAnalysis
|
|
37
|
+
from coretrace_python.dependency import (
|
|
38
|
+
ADVISORY_FILE,
|
|
39
|
+
DEPENDENCY_FILES,
|
|
40
|
+
POLICY_FILE,
|
|
41
|
+
Advisory,
|
|
42
|
+
AdvisoryFileError,
|
|
43
|
+
DependencyAnalysis,
|
|
44
|
+
DependencyGraph,
|
|
45
|
+
Policy,
|
|
46
|
+
apply_policy,
|
|
47
|
+
load_advisories,
|
|
48
|
+
load_policy,
|
|
49
|
+
parse_dependencies,
|
|
50
|
+
)
|
|
51
|
+
from coretrace_python.dependency.correlation import advisory_sinks, affected_symbols, correlate
|
|
52
|
+
from coretrace_python.findings import (
|
|
53
|
+
FINDING_SCHEMA_VERSION,
|
|
54
|
+
Confidence,
|
|
55
|
+
Coverage,
|
|
56
|
+
FileCoverage,
|
|
57
|
+
Finding,
|
|
58
|
+
Severity,
|
|
59
|
+
)
|
|
60
|
+
from coretrace_python.findings.refutation import RefutationAnalysis
|
|
61
|
+
from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
|
|
62
|
+
from coretrace_python.hir import HIR_SCHEMA_VERSION, nodes
|
|
63
|
+
from coretrace_python.interprocedural import (
|
|
64
|
+
CallGraph,
|
|
65
|
+
CallGraphAnalysis,
|
|
66
|
+
CallSite,
|
|
67
|
+
FunctionSummary,
|
|
68
|
+
ModuleGraph,
|
|
69
|
+
ProjectSummaries,
|
|
70
|
+
SummaryAnalysis,
|
|
71
|
+
SummaryIndex,
|
|
72
|
+
build_module_graph,
|
|
73
|
+
discover_sources,
|
|
74
|
+
project_symbol,
|
|
75
|
+
)
|
|
76
|
+
from coretrace_python.ir import PYIR_SCHEMA_VERSION
|
|
77
|
+
from coretrace_python.ir.defuse import DefUseAnalysis
|
|
78
|
+
from coretrace_python.ir.lowering import (
|
|
79
|
+
LoweringError,
|
|
80
|
+
ModuleIRAnalysis,
|
|
81
|
+
PyIRAnalysis,
|
|
82
|
+
analyzable_functions,
|
|
83
|
+
)
|
|
84
|
+
from coretrace_python.ir.ssa import SSAAnalysis
|
|
85
|
+
from coretrace_python.plugins import (
|
|
86
|
+
PLUGIN_API_VERSION,
|
|
87
|
+
Plugin,
|
|
88
|
+
PluginRegistry,
|
|
89
|
+
ProjectContext,
|
|
90
|
+
ProjectPlugin,
|
|
91
|
+
discover_plugins,
|
|
92
|
+
run_plugins,
|
|
93
|
+
)
|
|
94
|
+
from coretrace_python.reporters import Report
|
|
95
|
+
from coretrace_python.semantic import SEMANTIC_ANALYSES
|
|
96
|
+
from coretrace_python.semantic.imports import ImportAnalysis, ImportResolutionError, ImportTable
|
|
97
|
+
from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeError
|
|
98
|
+
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId
|
|
99
|
+
from coretrace_python.source import SourceFile, SourceManager, SourceSpan, decode_text
|
|
100
|
+
from coretrace_python.taint import (
|
|
101
|
+
EntryPoint,
|
|
102
|
+
ModelTable,
|
|
103
|
+
RegisteredRoutes,
|
|
104
|
+
Routes,
|
|
105
|
+
SecurityModelAnalysis,
|
|
106
|
+
SecurityModelRegistry,
|
|
107
|
+
TaintAnalysis,
|
|
108
|
+
TaintKind,
|
|
109
|
+
registered_routes,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
TOOL_NAME = "coretrace-python-analyzer"
|
|
113
|
+
|
|
114
|
+
# The plugins shipped with the package: models, detectors, secrets and dependency
|
|
115
|
+
# checks. They are loaded by default; ``--plugins`` adds directories on top.
|
|
116
|
+
BUNDLED_PLUGINS = Path(__file__).resolve().parent / "bundled"
|
|
117
|
+
|
|
118
|
+
ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
|
|
119
|
+
*SEMANTIC_ANALYSES,
|
|
120
|
+
CFGAnalysis,
|
|
121
|
+
DominanceAnalysis,
|
|
122
|
+
PostDominanceAnalysis,
|
|
123
|
+
PyIRAnalysis,
|
|
124
|
+
ModuleIRAnalysis,
|
|
125
|
+
SSAAnalysis,
|
|
126
|
+
DefUseAnalysis,
|
|
127
|
+
ConstantPropagation,
|
|
128
|
+
RangeAnalysis,
|
|
129
|
+
HeapAnalysis,
|
|
130
|
+
CallGraphAnalysis,
|
|
131
|
+
SummaryAnalysis,
|
|
132
|
+
ProjectSummaries,
|
|
133
|
+
SecurityModelAnalysis,
|
|
134
|
+
TaintAnalysis,
|
|
135
|
+
RefutationAnalysis,
|
|
136
|
+
DependencyAnalysis,
|
|
137
|
+
RegisteredRoutes,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Named so the set stays right whichever analyses a build registers.
|
|
142
|
+
_PROJECT_DEPENDANT_NAMES = frozenset(
|
|
143
|
+
{"interprocedural.project", "interprocedural.summaries", "taint.flows", "findings.refutation"}
|
|
144
|
+
)
|
|
145
|
+
_PROJECT_DEPENDANTS: frozenset[AnyAnalysis] = frozenset(
|
|
146
|
+
a for a in ALL_ANALYSES if a.name in _PROJECT_DEPENDANT_NAMES
|
|
147
|
+
)
|
|
148
|
+
MAX_PROJECT_ITERATIONS = 20
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ProjectSummariesUpdated(TransformationPass):
|
|
152
|
+
"""Invalidates every result that depends on the project-wide summary index."""
|
|
153
|
+
|
|
154
|
+
name: ClassVar[str] = "project.summaries-updated"
|
|
155
|
+
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
|
|
156
|
+
a for a in ALL_ANALYSES if a not in _PROJECT_DEPENDANTS
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def run(cls, ctx: AnalysisContext) -> None:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ResultsEvicted(TransformationPass):
|
|
165
|
+
"""Drops a module's PyIR and every derived result once its summaries, call sites and
|
|
166
|
+
findings are extracted (§30); the semantic tables and the engine inputs stay."""
|
|
167
|
+
|
|
168
|
+
name: ClassVar[str] = "project.results-evicted"
|
|
169
|
+
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
|
|
170
|
+
{*SEMANTIC_ANALYSES, SecurityModelAnalysis, ProjectSummaries, DependencyAnalysis, RegisteredRoutes}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def run(cls, ctx: AnalysisContext) -> None:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _no_keys() -> Mapping[str, str]:
|
|
179
|
+
return MappingProxyType({})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class ProjectAnalysis:
|
|
184
|
+
graph: ModuleGraph
|
|
185
|
+
index: SummaryIndex
|
|
186
|
+
findings: tuple[Finding, ...]
|
|
187
|
+
dependencies: DependencyGraph = field(default_factory=DependencyGraph)
|
|
188
|
+
keys: Mapping[str, str] = field(default_factory=_no_keys)
|
|
189
|
+
reused: tuple[str, ...] = ()
|
|
190
|
+
advisories: tuple[Advisory, ...] = ()
|
|
191
|
+
coverage: Coverage = field(default_factory=Coverage)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class FileAnalysis:
|
|
196
|
+
findings: tuple[Finding, ...]
|
|
197
|
+
coverage: Coverage
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_manager(
|
|
201
|
+
module: nodes.Module, models: SecurityModelRegistry | None = None
|
|
202
|
+
) -> AnalysisManager:
|
|
203
|
+
"""A manager with every engine analysis registered and the engine inputs provided."""
|
|
204
|
+
|
|
205
|
+
manager = _register_all(module)
|
|
206
|
+
manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze())
|
|
207
|
+
manager.provide(ProjectSummaries, SummaryIndex())
|
|
208
|
+
manager.provide(RegisteredRoutes, _routes_of(manager))
|
|
209
|
+
return manager
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _routes_of(manager: AnalysisManager) -> dict[SymbolId, EntryPoint]:
|
|
213
|
+
"""The handlers one module registers, given the models already provided."""
|
|
214
|
+
|
|
215
|
+
return registered_routes(
|
|
216
|
+
manager.module,
|
|
217
|
+
manager.get(ScopeAnalysis),
|
|
218
|
+
manager.get(SymbolAnalysis),
|
|
219
|
+
manager.get(SecurityModelAnalysis),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> PluginRegistry:
|
|
224
|
+
"""Every plugin below the roots, each manifest loaded once even when roots overlap."""
|
|
225
|
+
|
|
226
|
+
registry = PluginRegistry()
|
|
227
|
+
seen: set[Path] = set()
|
|
228
|
+
for root in plugin_roots:
|
|
229
|
+
for loaded in discover_plugins(root, manager):
|
|
230
|
+
if loaded.directory.resolve() not in seen:
|
|
231
|
+
seen.add(loaded.directory.resolve())
|
|
232
|
+
registry.add(loaded)
|
|
233
|
+
return registry
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def plugin_models(plugins: Iterable[Plugin]) -> ModelTable:
|
|
237
|
+
models = SecurityModelRegistry()
|
|
238
|
+
for plugin in plugins:
|
|
239
|
+
models.register(*plugin.models)
|
|
240
|
+
return models.freeze()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def check(source: SourceFile, plugin_roots: Sequence[Path]) -> tuple[Finding, ...]:
|
|
244
|
+
"""Run every plugin found under ``plugin_roots`` against one file."""
|
|
245
|
+
|
|
246
|
+
return analyze_file(source, plugin_roots).findings
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalysis:
|
|
250
|
+
"""One file's findings and coverage, with every plugin found under ``plugin_roots``."""
|
|
251
|
+
|
|
252
|
+
manager = _register_all(build_hir(source))
|
|
253
|
+
registry = load_plugins(plugin_roots, manager)
|
|
254
|
+
manager.provide(SecurityModelAnalysis, plugin_models(loaded.plugin for loaded in registry))
|
|
255
|
+
manager.provide(ProjectSummaries, SummaryIndex())
|
|
256
|
+
manager.provide(RegisteredRoutes, _routes_of(manager))
|
|
257
|
+
findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry))
|
|
258
|
+
functions = len(analyzable_functions(manager.module))
|
|
259
|
+
coverage = Coverage((FileCoverage(str(source.source_id), "analysed", functions, len(supported)),))
|
|
260
|
+
return FileAnalysis(findings, coverage)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_dependencies(root: Path, sources: SourceManager) -> DependencyGraph:
|
|
264
|
+
"""The requirements declared or pinned by the dependency files at ``root`` (§26)."""
|
|
265
|
+
|
|
266
|
+
graph = DependencyGraph()
|
|
267
|
+
candidates = sorted(root.glob("requirements*.txt")) + [root / name for name in DEPENDENCY_FILES]
|
|
268
|
+
for path in candidates:
|
|
269
|
+
if not path.is_file():
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
text = decode_text(path.read_bytes())
|
|
273
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
274
|
+
graph = graph.merge(DependencyGraph(errors=(f"{path}: {error}",)))
|
|
275
|
+
continue
|
|
276
|
+
graph = graph.merge(parse_dependencies(sources.add_source(str(path), text)))
|
|
277
|
+
return graph
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def analyze_project(
|
|
281
|
+
root: Path,
|
|
282
|
+
plugin_roots: Sequence[Path] = (),
|
|
283
|
+
plugins: Sequence[Plugin] = (),
|
|
284
|
+
cache: ProjectCache | None = None,
|
|
285
|
+
jobs: int = 1,
|
|
286
|
+
advisory_files: Sequence[Path] = (),
|
|
287
|
+
policy_file: Path | None = None,
|
|
288
|
+
) -> ProjectAnalysis:
|
|
289
|
+
"""Analyse every Python file under ``root`` with a shared summary index (§21) and the
|
|
290
|
+
dependency graph of its manifests (§26). ``plugins`` adds plugin instances to the
|
|
291
|
+
ones discovered under ``plugin_roots``. With a ``cache``, modules whose key is
|
|
292
|
+
unchanged since a previous run are served from it (§11). Modules are scheduled by
|
|
293
|
+
strongly connected components of the module graph, imports first; with ``jobs``
|
|
294
|
+
above one the components of a wave are analysed in that many processes (§29).
|
|
295
|
+
``advisory_files`` add to the ``advisories.json`` at ``root``; ``policy_file``
|
|
296
|
+
replaces the ``coretrace-policy.toml`` there."""
|
|
297
|
+
|
|
298
|
+
if jobs < 1:
|
|
299
|
+
raise ValueError("jobs must be at least 1")
|
|
300
|
+
sources = SourceManager()
|
|
301
|
+
findings: list[Finding] = []
|
|
302
|
+
dependencies = resolve_dependencies(root, sources)
|
|
303
|
+
for error in dependencies.errors:
|
|
304
|
+
findings.append(_note("syntax-error", error, sources.add_source(error.split(":")[0], ""), 1))
|
|
305
|
+
advisory_paths = _advisory_paths(root, advisory_files)
|
|
306
|
+
file_advisories: list[Advisory] = []
|
|
307
|
+
for path in advisory_paths:
|
|
308
|
+
try:
|
|
309
|
+
file_advisories.extend(load_advisories(path))
|
|
310
|
+
except AdvisoryFileError as error:
|
|
311
|
+
findings.append(_note("syntax-error", str(error), sources.add_source(str(path), ""), 1))
|
|
312
|
+
policy = Policy()
|
|
313
|
+
policy_path = policy_file if policy_file is not None else root / POLICY_FILE
|
|
314
|
+
if policy_path.is_file():
|
|
315
|
+
try:
|
|
316
|
+
policy = load_policy(policy_path)
|
|
317
|
+
except AdvisoryFileError as error:
|
|
318
|
+
findings.append(_note("syntax-error", str(error), sources.add_source(str(policy_path), ""), 1))
|
|
319
|
+
modules: dict[str, nodes.Module] = {}
|
|
320
|
+
files: dict[str, SourceFile] = {}
|
|
321
|
+
unreadable: list[tuple[Path, str]] = []
|
|
322
|
+
coverage: list[FileCoverage] = []
|
|
323
|
+
discovered = discover_sources(root, sources, unreadable)
|
|
324
|
+
for path, reason in unreadable:
|
|
325
|
+
findings.append(_note("syntax-error", f"{path}: {reason}", sources.add_source(str(path), ""), 1))
|
|
326
|
+
coverage.append(FileCoverage(str(path), "unreadable", 0, 0))
|
|
327
|
+
for source in discovered:
|
|
328
|
+
try:
|
|
329
|
+
modules[source.module_name] = build_hir(source)
|
|
330
|
+
except (ParseError, HIRBuildError) as error:
|
|
331
|
+
findings.append(_note("syntax-error", str(error), source, 1))
|
|
332
|
+
coverage.append(FileCoverage(str(source.source_id), "syntax-error", 0, 0))
|
|
333
|
+
continue
|
|
334
|
+
files[source.module_name] = source
|
|
335
|
+
|
|
336
|
+
managers = {name: _register_all(module) for name, module in modules.items()}
|
|
337
|
+
probe = next(iter(managers.values()), None) or _register_all(build_hir(sources.add_source("<empty>", "")))
|
|
338
|
+
registry = load_plugins(plugin_roots, probe)
|
|
339
|
+
all_plugins: tuple[Plugin, ...] = (*(loaded.plugin for loaded in registry), *plugins)
|
|
340
|
+
advisories = _merge_advisories(
|
|
341
|
+
(a for plugin in all_plugins for a in plugin.advisories), file_advisories
|
|
342
|
+
)
|
|
343
|
+
affected = affected_symbols(dependencies, advisories)
|
|
344
|
+
models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
|
|
345
|
+
for manager in managers.values():
|
|
346
|
+
manager.provide(SecurityModelAnalysis, models)
|
|
347
|
+
manager.provide(DependencyAnalysis, dependencies)
|
|
348
|
+
|
|
349
|
+
imports: dict[str, ImportTable] = {}
|
|
350
|
+
analysable: dict[str, AnalysisManager] = {}
|
|
351
|
+
for name, manager in managers.items():
|
|
352
|
+
try:
|
|
353
|
+
imports[name] = manager.get(ImportAnalysis)
|
|
354
|
+
except (ImportResolutionError, ScopeError) as error:
|
|
355
|
+
findings.append(_note("syntax-error", str(error), files[name], 1))
|
|
356
|
+
coverage.append(FileCoverage(str(files[name].source_id), "syntax-error", 0, 0))
|
|
357
|
+
continue
|
|
358
|
+
analysable[name] = manager
|
|
359
|
+
graph = build_module_graph(
|
|
360
|
+
{name: files[name] for name in analysable}, {name: modules[name] for name in analysable}, imports
|
|
361
|
+
)
|
|
362
|
+
routes: dict[SymbolId, EntryPoint] = {}
|
|
363
|
+
for name in sorted(analysable):
|
|
364
|
+
for symbol, registered in _routes_of(analysable[name]).items():
|
|
365
|
+
routes.setdefault(symbol, registered)
|
|
366
|
+
for manager in analysable.values():
|
|
367
|
+
manager.provide(RegisteredRoutes, routes)
|
|
368
|
+
|
|
369
|
+
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes)
|
|
370
|
+
keys = module_keys(
|
|
371
|
+
graph,
|
|
372
|
+
{name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
|
|
373
|
+
)
|
|
374
|
+
results: dict[str, CachedModule] = {}
|
|
375
|
+
if cache is not None:
|
|
376
|
+
for name in analysable:
|
|
377
|
+
entry = cache.load(keys[name])
|
|
378
|
+
if entry is not None:
|
|
379
|
+
results[name] = entry
|
|
380
|
+
reused = tuple(sorted(results))
|
|
381
|
+
|
|
382
|
+
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
383
|
+
pool = None
|
|
384
|
+
if jobs > 1:
|
|
385
|
+
pool = concurrent.futures.ProcessPoolExecutor(
|
|
386
|
+
max_workers=jobs, mp_context=multiprocessing.get_context("spawn")
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
for wave in graph.schedule():
|
|
390
|
+
pending = [c for c in wave if any(m not in results for m in c)]
|
|
391
|
+
computed: dict[str, CachedModule] = {}
|
|
392
|
+
if pool is None:
|
|
393
|
+
for component in pending:
|
|
394
|
+
batch = {name: analysable[name] for name in sorted(component)}
|
|
395
|
+
computed.update(_analyse_managers(batch, _seed(results, graph, component), module_plugins, affected))
|
|
396
|
+
for manager in batch.values():
|
|
397
|
+
manager.run(ResultsEvicted)
|
|
398
|
+
else:
|
|
399
|
+
futures = [
|
|
400
|
+
pool.submit(
|
|
401
|
+
_analyse_batch,
|
|
402
|
+
_Batch(
|
|
403
|
+
root,
|
|
404
|
+
tuple(plugin_roots),
|
|
405
|
+
tuple(plugins),
|
|
406
|
+
{name: _path_of(files[name]) for name in sorted(component)},
|
|
407
|
+
encode_index(_seed(results, graph, component)),
|
|
408
|
+
advisory_paths,
|
|
409
|
+
_encode_routes(routes),
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
for component in pending
|
|
413
|
+
]
|
|
414
|
+
for future in futures:
|
|
415
|
+
computed.update({name: decode(data) for name, data in future.result().items()})
|
|
416
|
+
for name, entry in computed.items():
|
|
417
|
+
if cache is not None:
|
|
418
|
+
cache.store(keys[name], entry)
|
|
419
|
+
results.update(computed)
|
|
420
|
+
finally:
|
|
421
|
+
if pool is not None:
|
|
422
|
+
pool.shutdown()
|
|
423
|
+
|
|
424
|
+
index = _seed(results, graph, frozenset())
|
|
425
|
+
call_graphs: dict[str, CallGraph] = {}
|
|
426
|
+
for name in sorted(analysable):
|
|
427
|
+
entry = results[name]
|
|
428
|
+
findings.extend(entry.findings)
|
|
429
|
+
unsupported = sum(1 for f in entry.findings if f.rule_id == "unsupported-syntax")
|
|
430
|
+
coverage.append(
|
|
431
|
+
FileCoverage(str(files[name].source_id), "analysed", len(entry.functions), len(entry.functions) - unsupported)
|
|
432
|
+
)
|
|
433
|
+
sites: dict[str, list[CallSite]] = {function: [] for function in entry.functions}
|
|
434
|
+
for site in entry.sites:
|
|
435
|
+
sites.setdefault(site.caller, []).append(site)
|
|
436
|
+
call_graphs[name] = CallGraph({}, {f: tuple(s) for f, s in sites.items()}, frozenset())
|
|
437
|
+
context = ProjectContext(graph, dependencies, advisories, analysable, call_graphs, policy, root)
|
|
438
|
+
for plugin in all_plugins:
|
|
439
|
+
if isinstance(plugin, ProjectPlugin):
|
|
440
|
+
findings.extend(plugin.analyze_project(context))
|
|
441
|
+
return ProjectAnalysis(
|
|
442
|
+
graph,
|
|
443
|
+
index,
|
|
444
|
+
apply_policy(policy, findings),
|
|
445
|
+
dependencies,
|
|
446
|
+
MappingProxyType(keys),
|
|
447
|
+
reused,
|
|
448
|
+
advisories,
|
|
449
|
+
Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _merge_advisories(
|
|
454
|
+
from_plugins: Iterable[Advisory], from_files: Iterable[Advisory]
|
|
455
|
+
) -> tuple[Advisory, ...]:
|
|
456
|
+
"""One advisory per identifier, package and range; a local file's version wins over
|
|
457
|
+
a plugin's, since the file is the project's own curated feed."""
|
|
458
|
+
|
|
459
|
+
merged: dict[tuple[str, str, str], Advisory] = {}
|
|
460
|
+
for advisory in (*from_plugins, *from_files):
|
|
461
|
+
merged[(advisory.id, advisory.package, advisory.vulnerable)] = advisory
|
|
462
|
+
return tuple(merged.values())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _advisory_paths(root: Path, advisory_files: Sequence[Path]) -> tuple[Path, ...]:
|
|
466
|
+
"""The advisory file at ``root``, when present, then the explicit ones."""
|
|
467
|
+
|
|
468
|
+
default = root / ADVISORY_FILE
|
|
469
|
+
return (*([default] if default.is_file() else []), *advisory_files)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _seed(results: Mapping[str, CachedModule], graph: ModuleGraph, component: frozenset[str]) -> SummaryIndex:
|
|
473
|
+
"""The summaries a component starts from: those of every module it imports,
|
|
474
|
+
transitively, all final by the time its wave runs; the whole index for no component."""
|
|
475
|
+
|
|
476
|
+
if component:
|
|
477
|
+
wanted: set[str] = set()
|
|
478
|
+
pending = list(component)
|
|
479
|
+
while pending:
|
|
480
|
+
for imported in graph.imports(pending.pop()):
|
|
481
|
+
if imported in results and imported not in wanted:
|
|
482
|
+
wanted.add(imported)
|
|
483
|
+
pending.append(imported)
|
|
484
|
+
else:
|
|
485
|
+
wanted = set(results)
|
|
486
|
+
return SummaryIndex(
|
|
487
|
+
{
|
|
488
|
+
project_symbol(name, function): summary
|
|
489
|
+
for name in sorted(wanted)
|
|
490
|
+
for function, summary in results[name].summaries.items()
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _analyse_managers(
|
|
496
|
+
managers: Mapping[str, AnalysisManager],
|
|
497
|
+
seed: SummaryIndex,
|
|
498
|
+
plugins: tuple[Plugin, ...],
|
|
499
|
+
affected: Mapping[SymbolId, Advisory],
|
|
500
|
+
) -> dict[str, CachedModule]:
|
|
501
|
+
"""Analyse one component: iterate its summaries to a fixpoint over ``seed`` (§21),
|
|
502
|
+
then extract what the rest of the run needs from each module."""
|
|
503
|
+
|
|
504
|
+
index = seed
|
|
505
|
+
for manager in managers.values():
|
|
506
|
+
manager.provide(ProjectSummaries, index)
|
|
507
|
+
for _ in range(MAX_PROJECT_ITERATIONS):
|
|
508
|
+
updated = SummaryIndex(
|
|
509
|
+
{
|
|
510
|
+
**seed.summaries,
|
|
511
|
+
**{
|
|
512
|
+
project_symbol(name, function): summary
|
|
513
|
+
for name, manager in managers.items()
|
|
514
|
+
for function, summary in _summaries_of(manager).items()
|
|
515
|
+
},
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
if updated == index:
|
|
519
|
+
break
|
|
520
|
+
index = updated
|
|
521
|
+
for manager in managers.values():
|
|
522
|
+
manager.run(ProjectSummariesUpdated)
|
|
523
|
+
manager.provide(ProjectSummaries, index)
|
|
524
|
+
return {name: _analyse_module(manager, plugins, affected) for name, manager in managers.items()}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass(frozen=True)
|
|
528
|
+
class _Batch:
|
|
529
|
+
"""One component handed to a worker process: it rebuilds the configuration from the
|
|
530
|
+
project root and the plugin roots, so only paths and the imported summaries travel."""
|
|
531
|
+
|
|
532
|
+
root: Path
|
|
533
|
+
plugin_roots: tuple[Path, ...]
|
|
534
|
+
plugins: tuple[Plugin, ...]
|
|
535
|
+
paths: Mapping[str, Path]
|
|
536
|
+
seed: Mapping[str, Any]
|
|
537
|
+
advisory_paths: tuple[Path, ...] = ()
|
|
538
|
+
routes: tuple[tuple[str, str, str, int], ...] = ()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
542
|
+
sources = SourceManager()
|
|
543
|
+
managers = {name: _register_all(build_hir(sources.load_file(path))) for name, path in batch.paths.items()}
|
|
544
|
+
registry = load_plugins(batch.plugin_roots, next(iter(managers.values())))
|
|
545
|
+
all_plugins: tuple[Plugin, ...] = (*(loaded.plugin for loaded in registry), *batch.plugins)
|
|
546
|
+
dependencies = resolve_dependencies(batch.root, sources)
|
|
547
|
+
file_advisories: list[Advisory] = []
|
|
548
|
+
for path in batch.advisory_paths:
|
|
549
|
+
try:
|
|
550
|
+
file_advisories.extend(load_advisories(path))
|
|
551
|
+
except AdvisoryFileError:
|
|
552
|
+
continue
|
|
553
|
+
advisories = _merge_advisories(
|
|
554
|
+
(a for plugin in all_plugins for a in plugin.advisories), file_advisories
|
|
555
|
+
)
|
|
556
|
+
affected = affected_symbols(dependencies, advisories)
|
|
557
|
+
models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
|
|
558
|
+
routes = _decode_routes(batch.routes)
|
|
559
|
+
for manager in managers.values():
|
|
560
|
+
manager.provide(SecurityModelAnalysis, models)
|
|
561
|
+
manager.provide(DependencyAnalysis, dependencies)
|
|
562
|
+
manager.provide(RegisteredRoutes, routes)
|
|
563
|
+
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
564
|
+
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
|
|
565
|
+
return {name: encode(entry) for name, entry in results.items()}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _path_of(source: SourceFile) -> Path:
|
|
569
|
+
assert source.path is not None, "project sources are loaded from files"
|
|
570
|
+
return source.path
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _encode_routes(routes: Routes) -> tuple[tuple[str, str, str, int], ...]:
|
|
574
|
+
return tuple(
|
|
575
|
+
sorted((str(symbol), str(entry.symbol), entry.label, entry.kinds.value) for symbol, entry in routes.items())
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _decode_routes(data: tuple[tuple[str, str, str, int], ...]) -> dict[SymbolId, EntryPoint]:
|
|
580
|
+
return {SymbolId(symbol): EntryPoint(SymbolId(registrar), label, TaintKind(kinds)) for symbol, registrar, label, kinds in data}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _configuration_key(
|
|
584
|
+
registry: PluginRegistry,
|
|
585
|
+
plugins: Sequence[Plugin],
|
|
586
|
+
models: ModelTable,
|
|
587
|
+
advisories: tuple[Advisory, ...],
|
|
588
|
+
dependencies: DependencyGraph,
|
|
589
|
+
routes: Routes | None = None,
|
|
590
|
+
) -> str:
|
|
591
|
+
"""Everything a module's results depend on besides the project sources (§11)."""
|
|
592
|
+
|
|
593
|
+
return fingerprint(
|
|
594
|
+
__version__,
|
|
595
|
+
str(HIR_SCHEMA_VERSION),
|
|
596
|
+
str(PYIR_SCHEMA_VERSION),
|
|
597
|
+
str(FINDING_SCHEMA_VERSION),
|
|
598
|
+
str(PLUGIN_API_VERSION),
|
|
599
|
+
*(
|
|
600
|
+
f"{loaded.manifest.name}={loaded.manifest.version}:{directory_fingerprint(loaded.directory)}"
|
|
601
|
+
for loaded in registry
|
|
602
|
+
),
|
|
603
|
+
*(f"{type(p).__module__}.{type(p).__qualname__}" for p in plugins),
|
|
604
|
+
repr(models),
|
|
605
|
+
repr(advisories),
|
|
606
|
+
repr(dependencies.requirements),
|
|
607
|
+
repr(dependencies.errors),
|
|
608
|
+
repr(_encode_routes(routes or {})),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _analyse_module(
|
|
613
|
+
manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Mapping[SymbolId, Advisory]
|
|
614
|
+
) -> CachedModule:
|
|
615
|
+
"""One module's findings, summaries and call sites: what the cache keeps (§11)."""
|
|
616
|
+
|
|
617
|
+
findings, supported = _check_module(manager, plugins)
|
|
618
|
+
graph = manager.get(CallGraphAnalysis)
|
|
619
|
+
correlated: list[Finding] = []
|
|
620
|
+
if affected:
|
|
621
|
+
for function in supported:
|
|
622
|
+
correlated.extend(
|
|
623
|
+
correlate(
|
|
624
|
+
graph.name_of(function),
|
|
625
|
+
manager.get(TaintAnalysis, function).flows,
|
|
626
|
+
manager.get(RefutationAnalysis, function),
|
|
627
|
+
affected,
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
return CachedModule(
|
|
631
|
+
graph.functions,
|
|
632
|
+
_summaries_of(manager),
|
|
633
|
+
tuple(site for function in graph.functions for site in graph.sites(function)),
|
|
634
|
+
(*findings, *correlated),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _summaries_of(manager: AnalysisManager) -> dict[str, FunctionSummary]:
|
|
639
|
+
table = manager.get(SummaryAnalysis)
|
|
640
|
+
return {name: table.summary(name) for name in table.names}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _check_module(
|
|
644
|
+
manager: AnalysisManager, plugins: tuple[Plugin, ...]
|
|
645
|
+
) -> tuple[tuple[Finding, ...], tuple[nodes.Function, ...]]:
|
|
646
|
+
"""Findings of the module plugins plus notes for unsupported functions, and the
|
|
647
|
+
functions that could be analysed."""
|
|
648
|
+
|
|
649
|
+
supported: list[nodes.Function] = []
|
|
650
|
+
notes: list[Finding] = []
|
|
651
|
+
for function in analyzable_functions(manager.module):
|
|
652
|
+
try:
|
|
653
|
+
manager.get(SSAAnalysis, function)
|
|
654
|
+
except (LoweringError, CFGError) as error:
|
|
655
|
+
notes.append(
|
|
656
|
+
Finding(
|
|
657
|
+
rule_id="unsupported-syntax",
|
|
658
|
+
message=str(error),
|
|
659
|
+
severity=Severity.INFO,
|
|
660
|
+
confidence=Confidence.HIGH,
|
|
661
|
+
span=function.span,
|
|
662
|
+
function=function.name,
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
supported.append(function)
|
|
667
|
+
findings = run_plugins(manager, plugins, tuple(supported))
|
|
668
|
+
return (*findings, *notes), tuple(supported)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _note(rule_id: str, message: str, source: SourceFile, line: int) -> Finding:
|
|
672
|
+
return Finding(
|
|
673
|
+
rule_id=rule_id,
|
|
674
|
+
message=message,
|
|
675
|
+
severity=Severity.INFO,
|
|
676
|
+
confidence=Confidence.HIGH,
|
|
677
|
+
span=SourceSpan(source.source_id, line, 1),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _register_all(module: nodes.Module) -> AnalysisManager:
|
|
682
|
+
manager = AnalysisManager(module)
|
|
683
|
+
manager.register(*ALL_ANALYSES)
|
|
684
|
+
return manager
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def report(findings: Sequence[Finding], coverage: Coverage | None = None) -> Report:
|
|
688
|
+
return Report(tuple(findings), TOOL_NAME, __version__, coverage)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Normalized findings produced by plugins and consumed by reporters."""
|
|
2
|
+
|
|
3
|
+
from coretrace_python.findings.coverage import Coverage, FileCoverage
|
|
4
|
+
from coretrace_python.findings.model import FINDING_SCHEMA_VERSION, Confidence, Finding, Severity
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"FINDING_SCHEMA_VERSION",
|
|
8
|
+
"Confidence",
|
|
9
|
+
"Coverage",
|
|
10
|
+
"FileCoverage",
|
|
11
|
+
"Finding",
|
|
12
|
+
"Severity",
|
|
13
|
+
]
|