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.
Files changed (126) hide show
  1. coretrace_python/__init__.py +4 -0
  2. coretrace_python/__main__.py +4 -0
  3. coretrace_python/abstract/__init__.py +45 -0
  4. coretrace_python/abstract/constants.py +226 -0
  5. coretrace_python/abstract/heap.py +252 -0
  6. coretrace_python/abstract/ranges.py +285 -0
  7. coretrace_python/abstract/values.py +56 -0
  8. coretrace_python/analysis/__init__.py +31 -0
  9. coretrace_python/analysis/manager.py +165 -0
  10. coretrace_python/analysis/provider.py +73 -0
  11. coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
  12. coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
  13. coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
  14. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
  15. coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
  16. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
  17. coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
  18. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
  19. coretrace_python/bundled/models/cli/cli_models.py +35 -0
  20. coretrace_python/bundled/models/cli/plugin.toml +9 -0
  21. coretrace_python/bundled/models/credentials/credential_models.py +43 -0
  22. coretrace_python/bundled/models/credentials/plugin.toml +9 -0
  23. coretrace_python/bundled/models/django/django_models.py +123 -0
  24. coretrace_python/bundled/models/django/plugin.toml +9 -0
  25. coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
  26. coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
  27. coretrace_python/bundled/models/flask/flask_models.py +53 -0
  28. coretrace_python/bundled/models/flask/plugin.toml +9 -0
  29. coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
  30. coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
  31. coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
  32. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
  33. coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
  34. coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
  35. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
  36. coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
  37. coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
  38. coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
  39. coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
  40. coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
  41. coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
  42. coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
  43. coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
  44. coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
  45. coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
  46. coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
  47. coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
  48. coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
  49. coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
  50. coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
  51. coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
  52. coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
  53. coretrace_python/bundled/security/xss/plugin.toml +9 -0
  54. coretrace_python/bundled/security/xss/xss.py +17 -0
  55. coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
  56. coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
  57. coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
  58. coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
  59. coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
  60. coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
  61. coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
  62. coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
  63. coretrace_python/cache.py +310 -0
  64. coretrace_python/cfg/__init__.py +46 -0
  65. coretrace_python/cfg/builder.py +589 -0
  66. coretrace_python/cfg/dominance.py +183 -0
  67. coretrace_python/cfg/model.py +166 -0
  68. coretrace_python/cli.py +216 -0
  69. coretrace_python/dataflow/__init__.py +29 -0
  70. coretrace_python/dataflow/lattice.py +78 -0
  71. coretrace_python/dataflow/solver.py +96 -0
  72. coretrace_python/dependency/__init__.py +44 -0
  73. coretrace_python/dependency/advisories.py +168 -0
  74. coretrace_python/dependency/correlation.py +87 -0
  75. coretrace_python/dependency/graph.py +274 -0
  76. coretrace_python/dependency/policy.py +65 -0
  77. coretrace_python/dependency/sbom.py +65 -0
  78. coretrace_python/engine.py +688 -0
  79. coretrace_python/findings/__init__.py +13 -0
  80. coretrace_python/findings/coverage.py +45 -0
  81. coretrace_python/findings/model.py +49 -0
  82. coretrace_python/findings/refutation.py +427 -0
  83. coretrace_python/frontend/__init__.py +18 -0
  84. coretrace_python/frontend/ast_adapter.py +447 -0
  85. coretrace_python/frontend/parser.py +23 -0
  86. coretrace_python/hir/__init__.py +5 -0
  87. coretrace_python/hir/nodes.py +584 -0
  88. coretrace_python/hir/visitors.py +36 -0
  89. coretrace_python/interprocedural/__init__.py +49 -0
  90. coretrace_python/interprocedural/callgraph.py +291 -0
  91. coretrace_python/interprocedural/modulegraph.py +218 -0
  92. coretrace_python/interprocedural/summaries.py +463 -0
  93. coretrace_python/ir/__init__.py +5 -0
  94. coretrace_python/ir/defuse.py +74 -0
  95. coretrace_python/ir/lowering.py +575 -0
  96. coretrace_python/ir/model.py +481 -0
  97. coretrace_python/ir/printer.py +201 -0
  98. coretrace_python/ir/ssa.py +277 -0
  99. coretrace_python/plugins/__init__.py +58 -0
  100. coretrace_python/plugins/api.py +153 -0
  101. coretrace_python/plugins/detectors.py +114 -0
  102. coretrace_python/plugins/loader.py +84 -0
  103. coretrace_python/plugins/manifest.py +112 -0
  104. coretrace_python/plugins/registry.py +34 -0
  105. coretrace_python/plugins/secrets.py +330 -0
  106. coretrace_python/reporters/__init__.py +22 -0
  107. coretrace_python/reporters/json_format.py +48 -0
  108. coretrace_python/reporters/report.py +23 -0
  109. coretrace_python/reporters/sarif.py +70 -0
  110. coretrace_python/reporters/text.py +24 -0
  111. coretrace_python/semantic/__init__.py +9 -0
  112. coretrace_python/semantic/identity.py +39 -0
  113. coretrace_python/semantic/imports.py +131 -0
  114. coretrace_python/semantic/scopes.py +473 -0
  115. coretrace_python/semantic/symbols.py +87 -0
  116. coretrace_python/source/__init__.py +13 -0
  117. coretrace_python/source/manager.py +81 -0
  118. coretrace_python/source/model.py +57 -0
  119. coretrace_python/taint/__init__.py +55 -0
  120. coretrace_python/taint/engine.py +800 -0
  121. coretrace_python/taint/models.py +317 -0
  122. coretrace_python/taint/routes.py +99 -0
  123. coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
  124. coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
  125. coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
  126. 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
+ ]