The objective is to build an extensible Python static analysis framework in which the engine exposes every stage of its pipeline and allows plugins to hook into the appropriate layer.
The engine must not be limited to security. Security is a collection of plugins built on top of a generic analysis infrastructure.
The system should eventually support:
- vulnerability detection;
- syntactic rules;
- code quality analysis;
- bug detection;
- dependency analysis;
- secret detection;
- framework analysis;
- configuration analysis;
- false-positive reduction;
- custom enterprise rules;
- CLI, JSON, and SARIF report generation.
Core principles:
- Intermediate representations are produced once and shared.
- A plugin declares the analyses it depends on.
- The engine computes analyses lazily whenever possible.
- Analyses are organized as a dependency DAG, not as a simple rigid chain.
- Plugins consume stable analysis results instead of accessing internal structures directly.
- Expensive analyses, especially taint, call graph, and interprocedural analysis, are shared.
- Plugins do not duplicate CFGs, IRs, or global results.
- IR transformations and read-only analyses are kept separate.
- The engine manages caching, dependencies, and analysis invalidation.
- Framework models, detection engines, and proof engines are kept separate.
Python Project
│
▼
Source Discovery
│
▼
Frontend (ast)
│
▼
PyHIR
│
┌─────────┼─────────┐
▼ ▼ ▼
Scopes Imports Symbols
└─────────┼─────────┘
▼
CFG
│
▼
SSA / PyIR
│
┌────────────┼────────────┐
▼ ▼ ▼
Data-flow Call Graph Abstract Value
│ │ │
└────────────┼────────────┘
▼
Function Summaries
│
▼
Taint Engine
│
┌────────────┼────────────┐
▼ ▼ ▼
Sources Sanitizers Sinks
└────────────┼────────────┘
▼
Findings
│
▼
Proof / Refutation
│
┌───────────┼───────────┐
▼ ▼ ▼
Vulnerability Hotspot Refuted
│ │
└──────┬────┘
▼
CLI / JSON / SARIF
This view is intentionally simplified. In practice, the engine must operate as an analysis dependency graph.
Responsibilities:
- discovering Python files;
- parsing;
- preserving source locations;
- collecting syntax errors;
- supporting incremental analysis in the future.
Pipeline:
Python source
↓
ast (standard library)
↓
PyHIR, through build_hir
The parser must not be exposed directly to advanced analyses.
Syntactic plugins may use PyHIR when appropriate, but semantic plugins must depend on PyHIR or PyIR.
Decision (2026-09-04): the frontend is the standard-library
astmodule, not Tree-sitter. The document was written with Tree-sitter as the parser. The engine was built behind thebuild_hirseam this section requires, with theastmodule as the only parser, and the architecture tests enforce that no layer above the frontend touches a parser object. Every later phase was delivered against that frontend. Tree-sitter would add a native dependency, a second adapter to keep identical to the first, and incremental parsing whose benefit the engine already gets elsewhere: the persistent cache (§11) is keyed per module, so an edit re-analyses that module and its importers only, and the whole test suite runs in about two seconds. Tree-sitter is therefore retired from the roadmap. The seam stays: a Tree-sitter adapter producing the same PyHIR remains possible if error recovery on broken files or sub-second editor feedback ever becomes a requirement.
PyHIR is a high-level representation independent of the parser.
Example:
cmd = request.args["cmd"]can become:
Assign
├── target: Name("cmd")
└── value:
GetItem
├── GetAttr(Name("request"), "args")
└── Constant("cmd")
Main instructions or nodes:
Module
Function
Class
Assign
AugAssign
Name
Constant
Call
Attribute
Subscript
If
While
For
Return
Import
ImportFrom
BinaryOp
UnaryOp
Compare
Await
Yield
Raise
With
Try
Lambda
Comprehension
Objective:
Decouple the parser from all subsequent analyses.
Starting from PyHIR, the engine builds several analyses.
PyHIR
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Scopes Imports Symbols
Responsibilities:
- local variables;
- global variables;
nonlocaldeclarations;- parameters;
- closures;
- function scopes;
- class scopes;
- comprehension scopes.
Example:
x = 1
def foo():
x = 2
return xThe engine must distinguish between the two x symbols.
Example:
import os as operating_system
from subprocess import run as executeResult:
operating_system -> python.os
execute -> python.subprocess.run
The engine should prefer canonical identifiers.
Examples:
python.builtins.eval
python.builtins.exec
python.os.system
python.subprocess.run
python.requests.get
flask.request.args
django.http.HttpRequest.GET
This ensures that security rules do not depend on the textual name visible in the file.
Each function is transformed into a Control Flow Graph.
Function
│
▼
Entry
│
▼
BasicBlock
│
├── instructions
└── terminator
Terminators:
Branch
Jump
Return
Raise
Switch-like constructs if needed
The CFG must support the computation of:
- predecessors;
- successors;
- dominance;
- post-dominance;
- reachability;
- loops;
- back edges;
- exception edges;
- control dependence.
Example:
if safe:
x = sanitize(x)
sink(x)becomes approximately:
entry:
branch %safe, bb_safe, bb_merge
bb_safe:
...
jump bb_merge
bb_merge:
...
call sink(...)
PyIR is the low-level analysis IR, but it must remain semantically faithful to Python.
Example instructions:
Const
Copy
LoadSymbol
StoreSymbol
GetAttr
SetAttr
GetItem
SetItem
Call
CallMethod
BinaryOp
UnaryOp
Compare
BuildList
BuildTuple
BuildDict
Branch
Jump
Return
Phi
Import
Await
Yield
Raise
WithEnter
WithExit
Avoid artificially reproducing LLVM's memory model.
Example:
obj.foomust remain:
%1 = get_attr %obj, "foo"
and must not be transformed into the Python equivalent of a getelementptr.
Local values are converted to Static Single Assignment form.
Example:
x = a
if cond:
x = b
use(x)becomes:
%x.0 = %a
branch %cond, bb_then, bb_merge
bb_then:
%x.1 = %b
jump bb_merge
bb_merge:
%x.2 = phi [%x.0, entry],
[%x.1, bb_then]
call use(%x.2)
Benefits:
- explicit def-use chains;
- constant propagation;
- taint propagation;
- abstract value analysis;
- straightforward handling of merges;
- dependency analysis between values.
The core of the system must be an AnalysisManager.
Responsibilities:
- registering available analyses;
- registering their dependencies;
- computing them only when needed;
- caching their results;
- invalidating results after transformations;
- exposing a uniform API to plugins.
Conceptual example:
symbols = ctx.get(SymbolAnalysis)
cfg = ctx.get(CFGAnalysis, function)
taint = ctx.get(TaintAnalysis, function)The plugin must not need to know how these results are built.
The actual pipeline is a graph.
Parse
│
▼
HIR
┌────────┼────────┐
▼ ▼ ▼
Scopes Imports Symbols
│ │ │
└────────┼────────┘
▼
CFG
│
▼
PyIR
┌───────┼─────────┐
▼ ▼ ▼
Dominators DataFlow AbstractValue
│
▼
CallGraph
│
▼
Summaries
│
▼
Taint
Dependencies must be declarative.
Example:
TaintAnalysis requires:
PyIR
CFG
SymbolAnalysis
CallGraph
FunctionSummaryAnalysis
The engine must not compute every analysis by default.
Example:
Plugin A -> PyHIR (syntax)
Plugin B -> PyHIR
Plugin C -> Symbols
Plugin D -> Taint
The engine computes only what is required.
An analysis may also be computed at function granularity.
Example:
ctx.get(CFGAnalysis, foo)
must not necessarily force construction of the CFGs for all 10,000 functions in the project.
Every analysis result must be cacheable.
Possible keys:
AnalysisType
+
ModuleId / FunctionId
+
IR fingerprint
+
Analysis version
+
Plugin/API version
Example:
(FunctionId, CFGAnalysis, IRHash)
↓
CFGResult
A persistent cache can later enable incremental analysis.
Read-only analyses do not modify the IR.
Transformations may invalidate other analyses.
Two families:
AnalysisPass
TransformationPass
Example:
SimplifyCFGPass
may preserve:
Symbols
Imports
Scopes
but invalidate:
Dominators
DataFlow
SSA
Taint
AbstractValue
The engine must manage this invalidation automatically.
Avoid:
plugin.on_stage("cfg", arbitrary_object)Prefer typed interfaces.
Example:
class SecurityPlugin:
requires = {
SymbolAnalysis,
TaintAnalysis,
}
def analyze(self, ctx: AnalysisContext) -> list[Finding]:
...Other plugin types:
FrontendPlugin
HIRPlugin
IRPlugin
AnalysisProvider
FrameworkModelPlugin
SecurityDetector
RefutationPlugin
ReporterPlugin
DependencyPlugin
SecretPlugin
ConfigPlugin
Example:
name: sql-injection
version: 1.0.0
plugin_api: 1
requires:
- semantic.symbols
- analysis.taint
provides:
- vulnerability.sql-injection
entrypoint:
module: plugins.sql_injection
class: SQLInjectionPluginFlask plugin:
name: flask-model
version: 1.0.0
plugin_api: 1
requires:
- semantic.symbols
provides:
- model.http-sources
- model.flask-routesThis distinction is important.
Produces facts.
Example:
FlaskPlugin
↓
route(handler)
request.args -> HTTP_SOURCE
The Flask plugin does not need to know about SQL injection.
Consumes generic facts.
Example:
HTTP_SOURCE
↓
Taint Engine
↓
SQL_SINK
↓
SQLInjectionDetector
This avoids:
FlaskSQLInjection
DjangoSQLInjection
FastAPISQLInjection
in favor of:
FlaskModel
DjangoModel
FastAPIModel
↓
Common Security Model
↓
SQLInjectionDetector
All plugins must be able to register models.
Example:
SourceModel
SinkModel
SanitizerModel
PropagatorModel
FrameworkModel
Example:
flask.request.args
-> Source(HTTP_INPUT)
python.os.system
-> Sink(COMMAND)
html.escape
-> Sanitizer(HTML)
The taint engine must be shared by all plugins.
Avoid:
SQL plugin -> own taint engine
Command plugin -> own taint engine
XSS plugin -> own taint engine
Prefer:
Global Taint Engine
│
┌────────────┼────────────┐
▼ ▼ ▼
SQL COMMAND HTML
Taint kinds can be represented using a bitset:
SQL = bit 0
COMMAND = bit 1
HTML = bit 2
PATH = bit 3
SSRF = bit 4
...
The join operation generally becomes:
OUT = A | B
Plugins register their models and consume the same taint result.
An SSA value may have an abstract state:
AbstractValue {
types
constants
taints
numeric_range
string_constraints
truthiness
nullability
}
Example:
%x:
type = str
taint = HTTP_INPUT
string_constraint = numeric_only
This supports:
- constant propagation;
- type approximation;
- guard validation;
- proof/refutation;
- finding prioritization.
Interprocedural analyses must use summaries.
Example:
def identity(x):
return xSummary:
RETURN depends_on PARAM[0]
Example:
def execute(cmd):
os.system(cmd)Summary:
PARAM[0] -> COMMAND_SINK
Possible structure:
FunctionSummary {
return_dependencies
return_taints
parameter_sinks
sanitizers
mutations
side_effects
calls
}
The call graph is built from:
- resolved symbols;
- imports;
- direct calls;
- type inference;
- framework models;
- potentially an approximate resolution of dynamic dispatch.
A
├── B
└── C
└── D
Because Python is dynamic, the call graph must support:
KnownTarget
PossibleTargets
UnknownTarget
The engine builds:
ModuleGraph
+
CallGraph
+
FunctionSummaryIndex
Example:
routes.py
↓
services.py
↓
repository.py
↓
database.py
Summaries must enable whole-project analysis without retaining all PyIR in memory.
SSA is not sufficient for:
a = []
b = a
b.append(user_input)
sink(a[0])Gradually introduce:
AllocationSite
AbstractObject
HeapLocation
AliasSet
Example:
List@foo.py:42.elements
-> HTTP_INPUT
The engine can start with a coarse abstraction and refine it later.
A finding must reference lightweight IDs.
Example:
Finding {
rule_id
severity
confidence
function_id
source_instruction_id
sink_instruction_id
message
metadata
}
Avoid storing a complete copy of the CFG or taint path.
The path can be reconstructed during reporting.
After initial detection:
Potential Finding
↓
Proof / Refutation
Sources of evidence:
Dominators
Post-dominators
Range analysis
String constraints
Type constraints
Path constraints
Sanitizers
Constant propagation
Authorization guards
Example:
value = request.args["id"]
if not value.isdigit():
return
sink(value)The engine can determine that, along the path to the sink:
value = numeric_only
The final result may become:
Vulnerability
Hotspot
Refuted
Organization of the plugins shipped inside the package (coretrace_python/bundled/,
loaded by default; --plugins DIR adds external directories, --no-bundled-plugins
leaves them out):
bundled/
├── syntax/
│ ├── dangerous_eval/
│ └── weak_crypto/
│
├── frameworks/
│ ├── flask/
│ ├── django/
│ └── fastapi/
│
├── security/
│ ├── sql_injection/
│ ├── command_injection/
│ ├── path_traversal/
│ ├── ssrf/
│ └── xss/
│
├── dependency/
│ ├── osv/
│ ├── lockfiles/
│ └── reachability/
│
├── secrets/
│ ├── generic_entropy/
│ └── providers/
│
├── proof/
│ ├── sanitizer/
│ ├── ranges/
│ └── path_constraints/
│
└── reporters/
├── cli/
├── json/
└── sarif/
The plugin system must not be limited to the PyIR pipeline.
Example:
requirements.txt
pyproject.toml
poetry.lock
uv.lock
│
▼
Dependency Resolver
│
▼
Dependency Graph
│
├── OSV Plugin
├── GHSA Plugin
├── Policy Plugin
└── Reachability Plugin
The Reachability Plugin can then depend on the Python engine's call graph.
Example:
Vulnerable Package
+
Affected API
+
CallGraph
↓
Reachable vulnerability
A global engine must be able to correlate multiple results.
Example:
SCA:
vulnerable dependency
SAST:
affected API imported
CallGraph:
vulnerable function reachable
Taint:
attacker-controlled value reaches call
↓
High-confidence exploitable finding
This is a major differentiator.
Reporters consume only normalized findings.
Findings
│
├── CLIReporter
├── JSONReporter
├── SARIFReporter
└── future UI/API
A reporter must not run an analysis itself.
Let:
N = program size
B = number of basic blocks
E = number of CFG edges
V = number of SSA values
C = number of call graph edges
P = number of plugins
The expected cost should be:
Ttotal =
Tcore
+ Tunique_analyses
+ Σ Tplugin_i
and not:
Ttotal =
P × Tcore
Shared analyses must be computed only once.
The target model is:
Mtotal =
Mshared_IR
+ Mshared_analysis_results
+ Σ Mplugin_state
and not:
Mtotal =
P × Mproject
Whenever possible, plugins should retain only:
FunctionId
BlockId
InstructionId
ValueId
SymbolId
FindingId
Suppose:
30 plugins
├── 8 syntax checks
├── 5 framework models
├── 10 security detectors
├── 3 proof plugins
├── 2 dependency plugins
└── 2 reporters
The engine must perform the following only once:
Parsing
PyHIR
Symbol resolution
CFG
SSA
CallGraph
Taint
AbstractValue
All 30 plugins share these results.
Plugins must not access internal classes directly.
Avoid:
engine._cfg_map
engine._symbol_table
engine._ir_storagePrefer:
ctx.functions()
ctx.cfg(function_id)
ctx.symbol(value_id)
ctx.calls(function_id)
ctx.taint(value_id)
ctx.abstract_value(value_id)
ctx.source_location(instruction_id)This API constitutes the Plugin API contract.
Plan for the following from the outset:
Plugin API v1
PyHIR schema version
PyIR schema version
Summary schema version
Finding schema version
A plugin manifest must be able to declare compatibility:
plugin_api: ">=1,<2"Several levels may eventually be supported.
Fast and simple.
Core Engine
↓
Python plugin module
Advantages:
- low overhead;
- direct access to APIs;
- easy development.
Disadvantage:
- a plugin can crash the engine.
Possible for untrusted plugins.
Core
↓
RPC / IPC
↓
Plugin Process
Safer but more expensive.
The engine can begin with in-process plugins while providing an abstraction compatible with future isolation.
Plugin:
SQLInjectionPlugin
Declares:
requires:
TaintAnalysis
SymbolAnalysis
The engine resolves:
SQLInjectionPlugin
│
├──── TaintAnalysis
│ │
│ ├── PyIR
│ ├── CFG
│ ├── Symbols
│ └── Summaries
│
└──── SymbolAnalysis
│
├── PyHIR
├── Imports
└── Scopes
The actual DAG becomes:
Parse
↓
PyHIR
↓
Scopes + Imports
↓
Symbols
↓
CFG
↓
PyIR
↓
Summaries
↓
Taint
↓
SQLInjectionPlugin
FlaskModelPlugin
↓
register HTTP sources
SQLAlchemyModelPlugin
↓
register SQL sinks
Taint Engine
↓
connect source → sink
SQLInjectionPlugin
↓
Finding
PathConstraintRefuter
↓
Finding validated/refuted
SARIFReporter
Each component remains independent.
engine/
├── frontend/
│ ├── parser/
│ └── source_manager/
│
├── hir/
│ ├── nodes/
│ ├── builder/
│ └── visitors/
│
├── semantic/
│ ├── scopes/
│ ├── imports/
│ └── symbols/
│
├── cfg/
│ ├── graph/
│ ├── builder/
│ └── dominance/
│
├── ir/
│ ├── values/
│ ├── instructions/
│ ├── blocks/
│ ├── functions/
│ └── ssa/
│
├── analysis/
│ ├── manager/
│ ├── registry/
│ ├── dependencies/
│ ├── cache/
│ └── invalidation/
│
├── dataflow/
│ ├── lattice/
│ ├── worklist/
│ └── solver/
│
├── abstract/
│ ├── values/
│ ├── ranges/
│ ├── strings/
│ └── types/
│
├── interprocedural/
│ ├── callgraph/
│ ├── summaries/
│ └── modulegraph/
│
├── taint/
│ ├── engine/
│ ├── models/
│ ├── sources/
│ ├── sinks/
│ └── sanitizers/
│
├── findings/
│ ├── model/
│ ├── correlation/
│ └── refutation/
│
├── plugins/
│ ├── api/
│ ├── loader/
│ ├── manifest/
│ └── registry/
│
├── reporters/
│ ├── cli/
│ ├── json/
│ └── sarif/
│
└── tests/
├── unit/
├── integration/
├── fixtures/
└── plugins/
SourceManager
Frontend (ast adapter; Tree-sitter retired, see §3.1)
PyHIR
Plugin API
Plugin Loader
Analysis Manager
Scopes
Imports
Symbols
Canonical Symbol IDs
CFG
PyIR
SSA
Def-use
Dominators
Worklist solver
Lattice API
AbstractValue
Constant propagation
Source/Sink/Sanitizer Registry
Global Taint Engine
Basic detectors
CallGraph
FunctionSummary
ModuleGraph
Fixpoint
Flask
Django
FastAPI
SQLAlchemy
Requests/httpx
Ranges
Path constraints
Dominant guards
Proof/refutation
SCA
Secrets
Dependency reachability
Correlation
SBOM
Persistent cache
Incremental parsing (covered by the per-module cache; Tree-sitter retired, see §3.1)
Incremental summaries
Parallel analysis
Memory eviction
A plugin requests an analysis; it does not recompute it.
Analysis results are shared and cached.
IRs are immutable for standard analyses.
Transformations explicitly declare what they invalidate.
Frameworks enrich the model; detectors detect vulnerabilities.
The taint engine is global and supports multiple taint kinds.
Interprocedural results use Function Summaries.
Plugins depend on a stable API, never on internal structures.
Analyses are lazy and, whenever possible, granular at the function level.
Findings use lightweight identifiers instead of graph copies.
┌──────────────────────┐
│ Plugin Manager │
│ discovery / manifest │
│ versions / loading │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Analysis Manager │
│ │
│ dependency DAG │
│ lazy evaluation │
│ caching │
│ invalidation │
└──────────┬───────────┘
│
┌───────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
Frontend Semantic PyIR
ast / PyHIR Scopes / Imports / Symbols CFG / SSA
│ │ │
└───────────────────────────┼──────────────────────────┘
▼
Generic Analyses
┌─────────────────┼─────────────────┐
▼ ▼ ▼
DataFlow CallGraph AbstractValue
│ │ │
└─────────────────┼─────────────────┘
▼
Function Summaries
│
▼
Taint Engine
│
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
Framework Models Detectors Proofs
│ │ │
└──────────────────────┼──────────────────────┘
▼
Findings
│
▼
Correlation Engine
│
┌────────────┼────────────┐
▼ ▼ ▼
Vulnerability Hotspot Refuted
│ │
└──────┬─────┘
▼
CLI / JSON / SARIF
The project must not be designed solely as a Python vulnerability scanner.
It must be designed as:
A programmable and extensible Python static analysis framework that provides a frontend, an HIR, an SSA IR, shared program analyses, and a plugin API for building specialized detectors.
Security then becomes a collection of plugins built on top of this engine.
This separation eventually allows the framework to host:
Security
Bug Detection
Code Quality
Performance
Compliance
Framework-specific analysis
Dependency Analysis
Supply-chain analysis
Custom enterprise policies
Research analyses
without duplicating the fundamental infrastructure.