CoreTrace Python Analyzer finds security vulnerabilities in Python code: injection flaws by following attacker-controlled data through the program, dangerous API usage, secrets committed in sources and configuration, and vulnerable or forbidden dependencies. It runs on a single file or a whole project, offline, with no runtime dependency.
pip install coretrace-python-analyzerPython 3.11 or later. The command coretrace-python-analyzer is installed with the
package; python -m coretrace_python is equivalent.
coretrace-python-analyzer --check src/Every Python file under src/ is analysed as one project and the findings are printed
one per line, followed by a count and a coverage line:
src/app.py:44:26: high command-injection: Command injection: http input reaches python.os.system through services.run_command [command_injection]
src/app.py:71:14: high dangerous-eval: call to python.builtins.eval executes dynamically built code [code_injection]
2 findings
coverage: 2/2 files, 12/12 functions
The exit status is 0 when nothing was found, 1 when findings were reported and 2 on a
usage or analysis error, so the command can gate a pipeline as it is. --fail-on high
keeps reporting every finding but fails only from that severity up.
coretrace-python-analyzer [--check | --emit-ir [--ssa]] [options] [path]
| Option | Effect |
|---|---|
--check |
Run the loaded plugins on path and report their findings. |
path |
A Python file, or a directory analysed as a project. |
--format {text,json,sarif} |
Report format, text by default. |
--plugins DIR |
Load the plugins found under DIR on top of the bundled ones. Repeatable. |
--no-bundled-plugins |
Do not load the plugins shipped with the package. |
--cache DIR |
Keep per-module results under DIR and reuse them for unchanged modules. |
-j N, --jobs N |
Analyse independent modules in N processes, 0 for one per available core. Default: 1. |
--sbom PATH |
Write a CycloneDX bill of materials of the dependencies to PATH. |
--vex PATH |
Write an OpenVEX document to PATH: whether each advisory affecting a requirement affects the project. |
--advisories FILE |
Read a local advisory file in addition to advisories.json at the root. Repeatable. |
--policy FILE |
Apply this dependency policy instead of coretrace-policy.toml at the root. |
--import-advisories SRC OUT |
Convert an OSV dump into the local advisory file OUT, then exit. |
--fail-on SEVERITY |
Exit with status 1 only when a finding of this severity or above was reported: info, low, medium, high or critical. Default: any finding. |
--baseline PATH |
The accepted findings: written on the first run, then only findings not recorded there fail the check. |
--baseline-key-file FILE |
With --baseline, the file holding the key of a keyed baseline (schema 4), kept out of the analysed directory; read before CORETRACE_BASELINE_KEY. |
--record-baseline |
With --baseline, record the current findings as the baseline, replacing the file only once the check succeeded. |
--emit-ir |
Print the intermediate representation of path instead of checking it. |
--ssa |
With --emit-ir, print the static single assignment form. |
--help |
Show the options and exit. |
--cache, --jobs, --sbom, --vex, --advisories and --policy apply to a directory check.
Given a directory, every .py file below it is a module of one project, named after
its package (app/views.py is app.views). A directory without __init__.py inside a
package is a portion of it, as Python reads one: pkg/sub/app.py is pkg.sub.app, and
its relative imports resolve against pkg. A module there can also be imported by the
name a script run from its directory gives it, app: an absolute import from that
directory reaches what lies beside it first, then what the package's root holds, and
files sharing only such a name are told apart by the importer's directory. From another
directory that name answers nothing, as Python finds no such module there: at the
project root, import os is the library even beside a pkg/sub/os.py. Two files
that would share a module name, a/app.py and z/app.py outside any package, are told
apart by their path from the root, a.app and z.app, and reported: each carries an ambiguous-module note, since a symbol
python.app.main may name a function of either and an import of app resolves only
from the importer's own directory, and each is covered as ambiguous rather than
analysed. An import of such a name that no file of the importer's own directory answers
is noted where it is written and not followed. Hidden directories, node_modules,
__pycache__, build, dist and any virtual environment, recognised by its
pyvenv.cfg whatever its name, are skipped. Dependency files at the root
(requirements*.txt, pyproject.toml, poetry.lock, uv.lock) are read into a
dependency graph. Configuration files under the root (.env, YAML, TOML, JSON, INI,
properties) are scanned for secrets.
Functions and methods are analysed, and so are the module body and the bodies of
top-level classes, as the functions <module> and Cls.<body>: application = get_asgi_application() in asgi.py or email = forms.EmailField() in a form class are
calls the analyzer sees, and a module-level os.system(input()) is a finding in <module>.
A function using syntax outside the supported subset is reported as an
unsupported-syntax note and the other functions are still analysed; a file Python
itself cannot parse is reported as a syntax-error, and so is a file whose syntax is
nested more than 200 levels deep (a chain of hundreds of + or chained calls, which only
generated code writes), which the analyzer does not follow; the other files are still
analysed. The coverage line and the JSON
report's per-file detail tell "no findings" from "nothing analysed".
Taint follows values between functions and across files through function summaries,
into objects (containers, attributes, instances of the project's own classes) and through
closures. Flask, FastAPI, Django, aiohttp, Tornado and Bottle route handlers, class-based views
and registered URL patterns receive HTTP input, and a method a handler class inherits
from its framework base, self.get_argument or self.request, denotes the base's; click and Typer commands receive argv input; input(),
sys.argv, environment variables, the output of local processes and the responses of
HTTP clients are further sources. The first three are operator-controlled: a command-line
tool opens the paths it is given, a command or a path built from the environment is the
operator's own doing, and a path read from a local process is not a traversal, so those
sources do not carry the corresponding kinds; a downloaded script piped into a shell
stays a command injection.
Findings carry a rule id, a severity (critical, high, medium, low) and a
confidence (high, medium, low).
Reported when attacker-controlled data reaches a sensitive call without passing through
a sanitizer for that kind of sink. The sanitizer may also sit inside a project function
the data goes through, like a helper that escapes before it answers, or a view that
redirects to reverse(...). It then protects every caller, on every path through the
function where it is called. What a function stores into an object or a nonlocal
variable is not treated that way yet.
| Rule | Reached sink |
|---|---|
command-injection |
Shell or process execution (os.system, subprocess with a string, …), judged on what the input controls in the command. A string a shell runs, a string naming the program, the program itself, or an operand the program runs as a command (after the host for ssh, the program of sudo, env, xargs, timeout…, any argument of a batch file) is high. The script an interpreter runs (bash run.sh, python tool.py, its first operand when no option such as -c gives the code), or the module of python -m, is high and kept for review: execution is possible, and its exploitability depends on the file the input chooses. The arguments after a script or code are the script's own: options of unknown meaning, reported medium (a hotspot after an option of the script), refuted behind a constant prefix. Otherwise an element of an argument list run without a shell can only be read as an option: medium, or high when a documented option of the program runs a command it chooses (git push --receive-pack=…, ssh -o ProxyCommand=…). A value a documented option takes is judged by what the option does with it, whatever its prefix: run it (sh -c, cmd /c, python -c: high), keep it as data (git commit -m: refuted). After an option the rule does not know, the finding stays a hotspot to review. A constant prefix that keeps a standalone input from being an option (f"v{version}"), or an operand after -- for a program that honours it, is refuted. Every case keeps the rule id command-injection, so suppressions and baselines match whatever the case. When the rule reads the command element by element and establishes what the input is there, the finding's injection metadata (in the JSON report, and as a property of its SARIF result) says so: command when the input runs or chooses what runs (a shell string, the program, an operand run as a command, the value of an option that runs it); option when it can only reach the program as an option or as the value of an option not established to run it, and the message is then titled Option injection. When that cannot be established (an option whose effect is to review, an unknown program or element before the input, a command that is not a list or tuple display, a sink such as os.system the rule does not read element by element), the metadata is absent and the title stays Command injection. |
code-injection |
The code eval or exec runs; the globals and locals passed with it are data. dangerous-eval reports every such call, this rule the ones attacker input reaches. Also the module importlib.import_module, __import__ or runpy.run_module imports, and the file runpy.run_path runs, by position or by keyword (name, mod_name, path_name); the package of a relative name, the globals and the run name are data. An import runs the top-level code of a module the input chooses among those that can be found, a script path the file it chooses: execution is possible, and how far it goes depends on what the input can reach, so these findings are titled Untrusted module import and Untrusted script execution. A constant package prefix ("app.plugins." + name) confines an import to that package: a hotspot. The environment and the command line reach these sinks as they reach eval. Also the directory sys.path.insert (its second argument), sys.path.append and sys.path.extend add to the import path, and the directory site.addsitedir adds (sitedir), judged the same way. A directory on the import path runs code only when a later import resolves to a module the attacker placed there: a hotspot, titled Untrusted import path. site.addsitedir runs the import lines of the directory's .pth files during the call: a vulnerability, titled Untrusted site directory, exploitable when the directory holds files the attacker controls, as the finding's evidence states. Coverage of the import path is partial: writes to sys.path that are not calls, a slice assignment (sys.path[:0] = [d]), sys.path += [d] or rebinding sys.path, are not reported. Also the name getattr(obj, name) receives, when the attribute it selects is called: the finding, titled Untrusted attribute call, is placed at each call of the result, one per call, with the lines of the selection and of the call in its metadata (selected_line, invoked_line), and through project functions at the call that reaches them. What runs depends on what the object exposes, so it is a hotspot, on a module too and behind a constant prefix; a check proves the name only when it limits the value getattr received to constants, where getattr selects (even past a join) or before the call, and a check of a name rebound since proves nothing; a check before the call does not prove a callable selected in an earlier iteration of a loop, which reaches the call across the loop's back edge: only a check where it was selected does (even when the name never changes, which this version does not establish); nothing is reported when the selection or the call cannot run, in the function and inside a project function. Calling the result of a call, or an item of it, calls what the call returned: its arguments are not read as the call's, so getattr(self, 'factory')(None, data) or eval('lambda x: x')(data) reports nothing. A getattr whose result is only read is not reported, nor, in this version, one whose result is returned to the caller, stored in an attribute or a container, or passed on before it is called, nor operator.attrgetter, operator.methodcaller, obj.__getattribute__(name) or vars(obj)[name]; checks made inside a project function are not seen from its callers, as for every sink. |
sql-injection |
A database statement (cursor.execute, SQLAlchemy text, Django raw, …). Parameters of a parameterised query are not statements. |
nosql-injection |
A NoSQL query, such as a MongoDB filter, given attacker input that may hold a structure: a JSON body giving {"$ne": null} matches any value. The input that may hold a structure is JSON, the raw bodies a json.loads decodes, files, WebSocket messages and standard input. Text cannot hold an operator: form fields, query parameters, headers, cookies, URL parameters, the command line, the environment, and an entry-point parameter annotated with scalars (str, int, Optional[str], Annotated[str, Query()], list[str], …), which FastAPI validates. A Django, REST framework, aiohttp or tornado view reads a request object: its text attributes (GET, POST, headers, query_params, query, match_info, arguments, …) and the URL parameters after it are text, its body, files and data are not. Limits: a helper function the request is passed to reads it as a whole; the keys of a dict are not followed, so a string concatenated into $where is not reported. The analyzer ships no NoSQL sink: model plugins declare them. |
path-traversal |
A file system path (open, send_file, os.remove, …). |
ssrf |
The URL of an HTTP client request (Requests, httpx, urllib), by position or as url=; the body, headers and query parameters are not the destination. How + and f-strings build the URL can prove part of its destination. When constant text fixes scheme://host and ends the authority with /, ? or # before any input ("https://api.example.com/users/" + id, or a module-level string bound once and never rebound), the flow is a hotspot: the input still chooses the path, which may reach a sensitive resource, and a redirect may lead elsewhere. With the path fixed too, the input reaching the query or fragment only, and the call disabling redirects (allow_redirects=False, follow_redirects=False), the destination is fixed and the flow is refuted. Anything unproven stays a vulnerability: a base of unknown value (imported, a call result), a constant leaving the authority open ("https:/", no / after the host, a variable port), urljoin, %, format, a URL built in a helper function. |
xss |
An HTTP response body built without escaping. A Django template rendered by render_to_string escapes, where the analyzer can establish it (below). |
insecure-deserialization |
pickle, marshal, dill, jsonpickle, shelve, and the YAML loaders (yaml.load, load_all, full_load, unsafe_load and their several-document versions); yaml.load and load_all given a safe loader (SafeLoader, BaseLoader) are not. |
open-redirect |
An HTTP redirect target, judged on the destination a browser resolves from the target's constant text, as + and f-strings build it. A reference on the current site keeps the browser there, where any further redirect is the project's own code, analysed on its own, so the flow is refuted. Such a reference is / followed by a character that is not a slash, a backslash, a space or a control character ("/profile/" + id), ?, #, or a relative path whose first segment ends in the text without a :. A fixed absolute host ("https://example.com/" + path) sends the browser there, but the input chooses the path, where that host may redirect again: a hotspot. Anything else stays a vulnerability. That covers "/" + next, since //evil.com is another host, a host left open, a base of unknown value and a target built in a helper function. |
plaintext-credential-storage |
A parameter named like a password stored in a database without hashing. Medium confidence, since a name is a hint. |
exploitable-vulnerability |
An API affected by an advisory of a vulnerable requirement, see Dependencies. |
Each flow is judged before it is reported. Code constant propagation proves can never
run (under if False:, while False:) reports nothing, in the function and through the
project functions that contain it: their summaries keep nothing of such code, neither a
sink nor a returned, merged or stored value. Code that may run is kept, whatever decides
it at run time, a parameter or a module-level name. A dominating guard that proves the value
safe (isdigit(), membership in a constant allowlist, equality with a constant, a
numeric value proven by int(), len() or bounded arithmetic, a validator such as
re.fullmatch) refutes it and nothing is reported. A sink that selects code by name,
the module of importlib.import_module, __import__ or runpy.run_module and the file
of runpy.run_path, is proven only by a check that limits the value to constants:
membership in a constant allowlist or equality with a constant. A check of its shape
(isidentifier(), isalnum(), re.fullmatch) limits its characters, not the modules
or files it can name, and leaves a hotspot. A hotspot is reported at medium
confidence. Two things make a flow a hotspot:
- A guard that examines the data without proving it safe. That covers a test of the value
itself (
if cmd:,len(cmd) < 10), of the same attribute, or of a method called on it (form.is_valid()). - An authorization decorator such as
login_required.
A guard that reads another attribute of the object the data comes from is no guard of
that data. if request.method == 'POST': before os.system(request.POST['cmd']) leaves
a vulnerability. When the flow passes the object whole, to the sink or to a function,
a guard on any of its attributes still counts. The verdict and its evidence are in the
finding's metadata.
A membership test (in, not in) is a constant allowlist when every member is a constant
that cannot change before the test:
- a tuple, list or set display written in the function, or a dict display by its keys,
tested directly or through
.keys(); frozenset,set,tupleorlistof one of these;- a module-level tuple, or
frozensetof a display, of constants, bound once in its module and never rebound there, by another assignment, an augmented assignment or aglobalstatement.
A list, set or dict of the function, a display or the set or list of one, counts only
while the function only reads it: changed by a method, stored, or passed to a call before
the test, it may hold anything. A
module-level list, set or dict can be changed from any module, so it never counts, and
the guard leaves a hotspot. Rebinding a module-level name from another module
(settings.ALLOWED = ...) is not seen, as for the module-level strings that fix a URL.
A lookup in a dict display, {"list": "ls"}[key] or .get(key, default), gives one of
its values, of what was stored in it since, or the default, never a key: input that only
selects the entry, or that is itself a key of the display, is not passed on. The same lookup in a module-level dict passes the key on, as any other
subscript does.
Django's render_to_string(name, context) returns HTML in which autoescaping escaped
every variable, so data reaching a response through it is no xss, if the template really
escapes. On a directory check, the analyzer reads the project's templates to decide it.
It establishes escaping only when all of the following hold:
- It finds every template of that name under a
templatesdirectory, by the path Django uses (app/page.html), and everything those templates extend or include. - None of them marks output safe:
|safe,|safeseqor{% autoescape off %}. - None of them uses a tag or filter beyond Django's own, or loads a library beyond its
built-ins (
static,i18n,l10n,tz,cache,humanize). - None of them places a variable where HTML escaping does not protect it: a
<script>or<style>block, an event handler (onclick="…"), astyleattribute, a URL attribute not fixed by a relative orhttp(s)://host/prefix, an unquoted attribute, or an attribute name. - No Python file of the project sets
autoescapetoFalse.
Templates are read as a browser's HTML tokenizer reads them, in time linear in the size
of the template. What a template leaves open at its end is checked too, since once the
template is included it runs into the markup that follows: a tag's attributes are
checked, a quoted value stays quoted until its quote closes, and a variable in a
<script> or <style> block or an end tag left open is refused, even where the page
ends there and the browser would never run it. A template including one that ends inside
such a block, a tag or an end tag is not established either: the markup after the
include continues it. A template extending another ends where that one ends. A comment
holding template tags may end at any of them, since an include there may close it and a
condition around its <!-- may leave it out, or run to its close when they render
nothing (#278). The markup is read on from its first tag, and that reading must be in
HTML data right after each later tag and right after the close, where the others
resume; where it is not, the template is not established. Such a comment left open does
not end in HTML data, since it may hide what follows.
Each page is also put together as Django renders it, and read once more (#273): the
templates it extends, each block taking the definition of the most derived one and {{ block.super }} the next one up, and each template it includes in place. What a child's
block prints inside its parent's <script>, or what an include puts inside an
attribute, is read where it lands. {{ block.super }} is put in place only bare: the
variable block named anywhere else, through a filter, a tag or an alias ({% with parent=block %}{{ parent.super }}), may still print the parent's markup, marked safe,
and the template is not established. A name several files have, which Django resolves
through loaders the analyzer does not read, counts as each of them: a page extending it
must escape with each file, except those already in its chain, which Django leaves out,
so that a template extending its own name extends another file of it; an include of it
is accepted only in HTML data, never inside an attribute, a tag, a <script> or
<style> block or a comment, and only when each file ends in HTML data, nothing of it
left open. A template including itself is read the same way. A page beyond 8 MiB once
put together, or a template with more than 64 pages, or 16 MiB in all, through the files
the templates it extends may be, is not established, and is listed among the templates
the analyzer could not read.
The analyzer never assumes escaping where it cannot read the template. That covers a
template file it cannot read, a template named by an expression, a template found
through a DIRS entry not named templates, one shipped by an installed package, one
rendered through get_template, and Flask's Jinja2 templates. The flow is then reported as before. A single-file check
reads no template.
| Rule | Reported call |
|---|---|
dangerous-eval |
eval or exec, whatever name the file gives them. |
weak-crypto |
hashlib.md5, hashlib.sha1 and other broken algorithms. |
debug-enabled |
app.run(debug=True) on a Flask application. |
missing-timeout |
A Requests or httpx call without a timeout. |
insecure-tls |
verify=False on a Requests or httpx call, or ssl._create_unverified_context(). |
unsafe-xml |
An XML parser of the standard library or of lxml, reported for what it risks; defusedxml is the safe replacement. The standard library's parsers run on Expat, which reads no local file but expands the entities a document declares: a crafted document can exhaust memory with Expat older than 2.7.2, which depends on the Python build at run time (pyexpat.EXPAT_VERSION). lxml resolves external entities by default before 5.0, and in iterparse and ETCompatXMLParser before 6.1 (CVE-2026-41066), which lets a crafted document read local files; resolve_entities=True does so whatever the version, and 'internal' keeps external entities out only from lxml 5.0 on, an earlier lxml reading the string as true; a parser given resolve_entities=False is not reported. XML content the project wrote itself, a string constant or a module-level string bound once passed to a function that parses its argument as the document (fromstring, parseString, XML), is reported at low confidence; a constant path keeps its confidence, since whoever can write the file chooses the document. |
unsafe-archive-extraction |
tarfile or zipfile extraction, or shutil.unpack_archive, without a member filter. |
insecure-temp-file |
tempfile.mktemp, which names a file without creating it. |
weak-random |
A random function whose result is assigned to a credential-like name, or produced by a function named like one. Medium confidence. |
One finding per literal, with a redacted preview and never the secret itself, in Python sources and in configuration files.
| Rule | Confidence | Trigger |
|---|---|---|
hardcoded-secret |
high | A provider-specific format: AWS, GitHub, Slack, Stripe, Google, private keys, JWTs, SendGrid, Twilio. |
hardcoded-credential |
medium | A credential-like name (password, token, api_key, app.config['SECRET_KEY'], …) bound to a real value. Placeholders are excluded. Low confidence in test code, fixtures and template files such as .env.example, with the context in the metadata, judged on the file's path in the project, not on where the project sits. |
high-entropy-string |
low | An opaque high-entropy token. Hex digests (MD5, SHA-1, SHA-256, SHA-512 lengths, or a name such as hash, checksum, commit), subresource-integrity hashes (sha512-…) and character-set constants are not secrets unless a credential name says so. |
| Rule | Trigger |
|---|---|
vulnerable-dependency |
A requirement allows a version affected by an advisory, reported at its line. |
reachable-vulnerability |
A call in the project to an API the advisory affects, or a read of an entry point marked read. |
exploitable-vulnerability |
Attacker-controlled data reaches such a call. Critical. |
denied-dependency |
A package the policy denies. |
unpinned-dependency |
A requirement without an exact pin when the policy requires pins. |
A finding whose line carries a # coretrace: ignore comment is suppressed. With a list
of rules, only those are:
data = yaml.load(text) # coretrace: ignore[insecure-deserialization]The comment also works in requirements files, where a line
pyyaml==5.3.1 # coretrace: ignore[vulnerable-dependency] silences that requirement's
finding. Suppressed findings are kept apart: the text report counts them, the JSON report
lists them under suppressed, the SARIF log marks them as suppressed in source, and
they never affect the exit status. A comment only suppresses a finding located at a
verified line; a finding with a file location (see Reports) is never suppressed this way.
A repository with known findings starts from a baseline:
coretrace-python-analyzer --check src/ --baseline coretrace-baseline.jsonThe first run writes the file with every current finding and passes. Later runs set the
recorded findings apart and fail only on new ones. A finding is recognised by its file,
its rule, its function and the text of every line it covers, not by its line number, so
code inserted above it does not make it new; a change to its text does, on any of its
lines, such as the body of a key written over several lines. Commit the file and shrink
it as findings are fixed: an entry without a matching finding is simply unused.
Baselined findings are counted in the text report, listed under baselined in the JSON
report and marked baselineState: unchanged in the SARIF log, where new results are
marked new. A finding with a file location is recognised by the JSON pointer of its
value and its value instead of the text of a line, so a new value at the same place is a
new finding. Entries hold a digest of that text, value and pointer, never the text
itself, so no accepted secret is stored in plain text. That does not make the file
confidential: a digest does not hide a weak secret from whoever guesses it, since a
guessed password, hashed with its line, can be checked against the file. Treat the
baseline as a possibly sensitive file, or key it.
A keyed baseline does not let candidate values be tested without its key. The key comes
from the file --baseline-key-file names, or else from the CORETRACE_BASELINE_KEY
environment variable, as a CI secret; the file comes first when both are given. The check
refuses a key file inside the analysed directory, however its path is spelled, or the
baseline itself; keep it out of the whole repository too, which the check cannot tell
(a hard link escapes it as well). Generate the key at random, for instance with
python -c "import secrets; print(secrets.token_hex(32))", and share it as a secret
(a CI secret, a password manager): a key a person chooses can be guessed from the
baseline. It is at least 16 bytes long, and trailing line breaks are not part of it, from
the file or the variable alike; an empty variable is an error, as a CI job whose secret
is missing passes one. Every process of the check, plugins and --jobs workers included,
can read the variable. With a key, the
baseline is recorded in schema 4: every digest is an HMAC-SHA-256 under the key, and a
check value, never the key, tells which key recorded it. The key appears in no report,
message or file the check writes. A schema 4 baseline needs its key: without it, or
with another, the check stops with an explicit error, exit status 2, and records
nothing. Recording over a schema 4 baseline without its key is refused too, rather than
turning it back into an unkeyed one: delete the file to do that explicitly. Without a
key, baselines are recorded in schema 3, with the guarantee above.
--record-baseline records the current findings as the baseline even when the file
exists, under the key given, if any; the file (the target of a symbolic link) is
replaced at once, keeping its permissions, only once the analysis succeeded and its
report is rendered, so the previous baseline stays in place when the check fails. Review the findings
before recording: they all become accepted. Rotating the key is such a record under the
new key, once the findings are reviewed; the old key then no longer reads the baseline.
Baselines are written in schema 3, or in schema 4 with a key. A file written by an
earlier version, in schema 1 or 2, is still read and never rewritten, and the check warns
on standard error, once per run, that it may contain secrets in plain text: its entries
hold the text of each
finding's first line. They match exactly as they did a finding on a single line; a
finding over several lines, whose other lines they do not hold, is reported as new, and
so is a finding in a JSON, TOML or lock file that a schema 1 entry, recorded with the
text of its key's first line, cannot identify with certainty. The exit status depends
on the findings only. Review such a baseline and its new findings, then migrate it
explicitly with --record-baseline, to schema 3, or to schema 4 with a key. While a key
is given, only a schema 4 baseline is read: an unkeyed one (schema 1, 2 or 3) stops the
check with an error, so that it cannot stand in for the keyed one; review it, then record
it in schema 4 with --record-baseline. No baseline is ever converted or rewritten
without --record-baseline. The
old file's secrets remain in the history of a repository that committed it.
--format accepts text, json and sarif.
--format text prints one line per finding, path:line:column: severity rule: message,
then the count and the coverage line. Paths are relative to the checked directory, or to
the directory of the checked file; a path outside it is printed as it is.
--format json prints one document:
{
"schema_version": 1,
"tool": {"name": "coretrace-python-analyzer", "version": "0.25.0"},
"root": "/home/me/project",
"findings": [
{
"rule_id": "sql-injection",
"message": "SQL injection: http input reaches python.sqlite3.connect.cursor.execute",
"severity": "high",
"confidence": "high",
"location": {"path": "app.py", "line": 50, "column": 21, "end_line": 50, "end_column": 60},
"function": "user",
"metadata": {"source": "python.flask.request.args", "verdict": "vulnerability"}
}
],
"coverage": {
"files": 2, "files_analysed": 2, "functions": 12, "functions_analysed": 12,
"details": [{"path": "app.py", "status": "analysed", "functions": 9, "analysed": 9}]
}
}Lines are counted as Python counts them, by \n, \r\n or a lone \r (never U+2028
or a form feed), in every report, suppression and baseline. A value of a JSON or TOML
file, a package of a lock file, or a requirement of pyproject.toml, is located at its own line,
which a separate component establishes from the value's structural path and checks
against the value. When it cannot (a dotted TOML key, an inline table, a multi-line
value), the finding gets a file location instead of a guessed line: line, column,
end_line and end_column are null, and pointer gives the JSON pointer of the value
in the file ("/tool/app/api_token"). The SARIF log gives such a result no region,
with the pointer under the location's properties, and the text report shows the file
alone (settings.toml: high hardcoded-credential: …).
A finding in test code carries context: test in its metadata, in the JSON report and as
a property of its SARIF result. Test code is a file under a test, tests, testing,
fixtures or __tests__ directory of the project, or a file named test_*.py,
*_test.py or conftest.py, judged on its path from the root of the report, not on
where the project sits. For a file checked alone, that root is the file's own directory,
as the report gives it, so a test_*.py module is test code either way, while a test
directory above the file is seen only when its directory is checked. The context
changes no verdict: it is there for baselines, --fail-on
wrappers, readers and refiner plugins, which see it. A rule may still give its own
context, as hardcoded-credential does with example for template files, and the
SARIF result carries that context too.
A finding in vendored code, under a third_party, third-party, vendor or vendored
directory, carries origin: vendored, in the JSON report and as a property of its SARIF
result. The origin is told apart from the context: the tests of a vendored project
carry both context: test and origin: vendored. Vendored code is still analysed and
its findings reported, so that a flow from the project through a vendored module is
not lost; the origin changes no verdict either.
root is the directory the paths are relative to. A file's status is analysed,
ambiguous (analysed, but another file has its module name, see above), syntax-error
or unreadable; only analysed files count in files_analysed.
A directory check also lists, in tool.components, what the result was produced with
besides the engine and the sources: each plugin it loaded, by manifest name and version,
and each advisory file it read, by path (relative to the root when under it). Each
component carries the SHA-256 digest of its content, so a result can be traced to the
exact advisory data it used and reproduced. A plugin's digest covers its directory as the
cache fingerprints it, bytecode and hidden files left out; a file's digest covers its
bytes. A single-file check lists no components.
"tool": {
"name": "coretrace-python-analyzer", "version": "0.25.0",
"components": [
{"kind": "plugin", "name": "curated-advisories", "version": "2026.09.25", "digest": "sha256:…"},
{"kind": "advisories", "name": "advisories.json", "digest": "sha256:…"}
]
}A finding about an advisory names, in its advisory_source metadata, where the advisory
comes from. For a plugin this is name@version, and for an advisory file its path. When
several sources define the same advisory, it names the one whose definition is used, as
the next section describes.
--format sarif prints a SARIF 2.1.0 log, one run with the tool, its rules and one
result per finding. Each rule carries a title (shortDescription) and what it detects
(fullDescription), so that a code scanning service can explain a finding: the plugin that
reports a rule describes it, the engine its own notes, and a rule a plugin does not describe
keeps its id alone. Each result carries the finding's confidence as the confidence
property. A flow that reaches its sink inside a project function is reported at the call, and
points at the sink as a related location (relatedLocations), with the message
sink <sink>, reached via <function>, which the result's message links to. The root is
declared once as the SRCROOT original URI base and
every location under it is relative to that base, which is what code scanning services
need to attach results to files. The components of a directory check are the tool's
extensions, their kind and digest in properties:
coretrace-python-analyzer --check src/ --format sarif > report.sarifThe bundled sample-advisories plugin ships a small offline database. A real feed stays
offline too: convert a public OSV dump once, then read it at every check.
coretrace-python-analyzer --import-advisories osv-dump.zip advisories.json
coretrace-python-analyzer --check src/ --advisories advisories.jsonA directory check reads advisories.json at the project root and every file passed
with --advisories; a local entry wins over a plugin's for the same advisory. The file
lists advisories with the package, the vulnerable version range (a specifier, or several
separated by || when several release series are affected) and, optionally, the
affected APIs that feed reachability and correlation:
{
"schema": 1,
"advisories": [
{
"id": "CVE-2020-1747",
"package": "pyyaml",
"vulnerable": "<5.4",
"summary": "yaml.load can execute arbitrary code from untrusted documents",
"severity": "critical",
"affected_symbols": ["python.yaml.constructor.FullConstructor.construct_python_object_apply"],
"entry_points": [
{
"symbol": "python.yaml.full_load",
"justification": "FullLoader uses FullConstructor (commit 5080ba5)",
"conditions": [
{"kind": "semantic", "text": "the document carries a python/object/apply tag"}
]
},
{
"symbol": "python.yaml.load",
"justification": "same path when Loader is FullLoader",
"conditions": [
{"kind": "argument", "text": "Loader is FullLoader, the default", "argument": "Loader", "position": 1,
"values": ["python.yaml.FullLoader", "python.yaml.loader.FullLoader"], "default": true},
{"kind": "semantic", "text": "the document carries a python/object/apply tag"}
]
}
],
"modules": ["yaml"],
"aliases": ["GHSA-8q59-q68h-6hv4"]
}
]
}affected_symbols are the functions the fix changed; entry_points the public APIs
through which a project reaches them, each with the justification that ties it to the
fix — the commit, or the call path — and the conditions under which it is affected. A
call to either kind of symbol is reachable. modules names the top-level modules the
package installs, so the analyzer can tell an imported package from a merely required
one; it defaults to the package name.
An entry point marked "read": true is an attribute whose getter runs the affected code,
such as a request body parsed on first access
({"symbol": "python.flask.request.form", "justification": "…", "read": true}): reading
it is reachable, called or not — request.form['name'], for key in request.form,
request.form.get('name') all read it. A function reading it several times is reported
once, where it first reads it.
An entry point may declare suffixes, for receivers the engine cannot type. A project
reaches python.django.db.models.QuerySet.annotate through a manager on its own model,
Item.objects.annotate(...), whose call derives the project's own symbol
python.app.models.Item.objects.annotate — Item is a project class, objects an
attribute the engine does not type — so the canonical entry point never matches. A
suffix matches any symbol of the project's own modules ending with a dot plus the
suffix:
{"symbol": "python.django.db.models.QuerySet.annotate", "justification": "…",
"suffixes": ["objects.annotate", "objects.filter.annotate"]}A queryset chain is declared explicitly: Item.objects.filter(...).annotate(...)
derives …objects.filter.annotate, which only the second suffix matches. A suffix is
at least two dot-separated names, so a bare method name, matching every project method
of that name, cannot be declared; another package's symbol, such as
other_lib.objects.annotate, never matches. The finding names the advisory's symbol as
entry_point and the derived one as symbol, and the entry point's conditions and
attacker_arguments apply through a suffix as through the exact symbol. A project
attribute chain genuinely ending with a declared suffix matches too — the vulnerable
version range and the conditions still gate what is reported.
A filter of Django's own libraries that a project template applies is a call to the
function behind it, placed at the template's line: {{ bio|striptags }} calls
python.django.template.defaultfilters.striptags, and so do filters in tag arguments
({% if bio|striptags %}) and {% filter %} blocks. A template counts whether or not a
Python call names it, since a class-based view renders its template_name inside
Django. Most filters are functions of the same name in django.template.defaultfilters;
escape, escapejs, linebreaks, phone2numeric, slice, timesince and timeuntil
are <name>_filter there, and the filters of i18n, l10n, tz and humanize are in
their library's module (timezone is django.templatetags.tz.do_timezone).
A project tag library, a module of a templatetags directory, that registers a filter
of that name replaces the built-in in every template loading it, as Django's parser
does: {% load custom %}{{ bio|striptags }} calls the project's function when
custom.py registers a striptags filter, which is no call to the built-in; the
project's function is ordinary code, whose own calls are analysed as any other. A
load affects only the filters applied after it in the file, a later load overrides an
earlier one, {% load striptags from custom %} replaces only the filters it names, and
Django's own libraries (static, i18n, l10n, tz, cache, humanize) change
nothing. The analyzer reads the registrations on the library's register, a
django.template.Library however it is imported: @register.filter,
@register.filter("name"), @register.filter(name="name", is_safe=True),
register.filter("name", function) and register.filter(function), at module level or
under if, try or a function. Where it cannot tell what a library registers, the
filter is neither the built-in nor certainly replaced: a library found in no
templatetags directory (one an installed package ships, or that the settings'
OPTIONS["libraries"] or OPTIONS["builtins"] name, which are not read), a file it
cannot read or parse, a name given by a variable or unpacked arguments, register
passed to another function, filled through filters= or register.filters, or bound
to anything but a Library, or two apps giving the same library name and registering
different filters. The analyzer then claims no call, and counts the template among those
it could not read (below) when a filter of the library's name is applied under the load.
Attacker input in the render context makes the filter exploitable at the render call:
render(request, "app/profile.html", {"bio": request.POST["bio"]}) with
{{ bio|striptags }}. The analyzer links a context value to a filter only where it is
certain of it:
- The context is a dict literal with constant keys, used by the render call and
nothing else, written in the call or through a variable. A dict mutated or passed
elsewhere, one unpacking another (
**extra), or a context built any other way leaves the filter reachable. - The filter's value is a context variable, directly or through
{% for %}and{% with %}, the templates the template includes or extends by a constant name, the arguments of the filters before it, or the output of a{% filter %}block. A name a tag binds shadows the context, and{% include ... only %}passes none of it. A cycle of includes is followed as far as Django may render it: a template reached again on the way applies its filters to what each include binds at every level, as a template including itself with other bindings, or a recursive tree, does (#284). - The filter renders: a template extending another renders only its blocks, and only
where its chain of parents places them (#285). The root's blocks are placed; a placed
block renders its most derived definition, and through
{{ block.super }}the next ones up, which keep the parent's content and the filters it applies; the blocks a rendered definition holds are placed in turn. A block no template of the chain places never renders, nor does a block whose only placeholder a child replaces. When the chain cannot be followed to a root (a parent named by an expression, not found or in several files, or an extends cycle), every block of the chain counts as placed. - One file has the template's name; of two files of the same name, which one renders depends on the loaders.
The filter's value is its first argument and its argument (cut:bio) its second, so an
entry point naming only the value in attacker_arguments is not exploitable through
the argument. A project function rendering the values it receives carries them the same
way.
With the django.template.context_processors.request context processor, a template
rendered with the request reads it as request: {{ request.GET.q|striptags }} is
exploitable at the render call too. The analyzer counts it only where all of these hold:
- The project's settings assign
TEMPLATESa literal list at module level, everyDjangoTemplatesengine of it lists the processor in a literalcontext_processors, and no other code of the project names the module'sTEMPLATES, or aTEMPLATESit does not bind, as a star import gives. ATEMPLATESbound in a function or a class is another name: it configures nothing and changes nothing. A mere mention of the processor proves nothing. - The render call passes the request:
renderandTemplateResponsealways do,render_to_stringwhen givenrequest,SimpleTemplateResponsenever. - The render's context is certain, absent or a dict literal as above, so it cannot hide
a
requestentry. An entry of that name, a name a tag binds or{% include ... only %}shadows the processor's request. - The template reads text the user controls: an attribute the request object lists
among its text attributes, such as
GET,POST,COOKIESorheaders; notuserorsession.
Through a project function, only the context entries are followed.
Most vulnerabilities need attacker input in one argument: the path send_from_directory
serves, not its download_name; the URL requests.get fetches, not its body. An entry
point may name them in attacker_arguments, by keyword argument and, when it may be
passed positionally, by position, counted as for argument conditions; "variadic": true
extends a position to every later one, as safe_join(directory, *pathnames) takes
({"position": 1, "variadic": true}). A call is then exploitable only when attacker
input reaches one of them, "attacker_arguments": [{"argument": "url", "position": 0}]
for requests.get; attacker input in another argument leaves it reachable. An argument
unpacked with *args or **kwargs may fill any of them: like an argument condition it
leaves undecided, it does not rule the call out, so it counts, and a wrapper forwarding
*args, **kwargs to the entry point still reaches it. An entry point without
attacker_arguments is exploitable through any argument.
An argument condition is decided at each call. It names the argument by keyword and,
when it may be passed positionally, by position (counted from 0, the receiver of a
method excluded); values are what makes the call affected, written as the analyzer
sees arguments: a symbol by its canonical name (python.yaml.FullLoader, and every
spelling a project may use, such as python.yaml.loader.FullLoader), a constant as
Python writes it (True, None, '/static'); default says an absent argument means
an affected value. A call passing another known value does not reach the vulnerability;
a call passing a variable, or unpacking *args or **kwargs, leaves the condition
pending review. A semantic condition — the document's tags, the platform, the
template's origin — cannot be decided and is always pending review, never dropped.
An argument condition with "present": true says the argument being passed at all,
whatever its value, is the condition, as a \r in web.Response(reason=…) needs
reason given, constant or not: {"kind": "argument", "text": "…", "argument": "reason", "position": 1, "present": true}, exclusive with values and default. A
call giving the argument — positionally, by keyword, or through a ** literal with
constant keys, which is folded into the keywords — meets it even when the value is
unknown; a call surely not giving it does not reach the vulnerability and is ruled out
((reason absent)); a call unpacking *args or a **mapping whose keys the engine
cannot read leaves it pending review.
A host condition says the attacker must choose the host of the URL passed as
argument (or at position), as credentials leaking to the host of a crafted URL
need: {"kind": "host", "text": "…", "argument": "url", "position": 0}. It is
decided from the URL's constant text, as ssrf and open-redirect read it, when the
attacker's input is passed in that argument. The outcome has three cases:
- Constant text fixes the host (
"https://api.example.com/" + path) and the call disables redirects (allow_redirects=False,follow_redirects=False): the call is reachable, not exploitable. - Constant text fixes the host but redirects may be followed: the call stays exploitable. The fixed initial host does not clear the chain, since a redirect may lead to a host the attacker controls, and the condition pending review says so. Input in the query does not choose the host.
- Otherwise the condition stays pending review.
The other conditions, the environment included, stay pending as before.
A keyword_name condition says the attacker must choose a keyword name of the call,
as Django's ORM takes column aliases and lookups (annotate(**{alias: expr})):
{"kind": "keyword_name", "text": "…"}, and nothing else. The engine decides it from
the keys of what the call expands with **; the values of the mapping do not decide
it. The outcome has three cases:
- Keys carrying attacker input meet it, and the call is exploitable. Key taint is
carried, apart from value taint, for a dict literal
{alias: expr},dict([(alias, expr)]), a comprehension{k: expr for k in request.GET}and a subscript assignmentmapping[alias] = expr, and copied by{**mapping}anddict(mapping). - Constant keys — a dict literal with string keys,
dict(total=…), plain keywords — contradict it, whatever the values carry: the call is ruled out (app.views:12 python.app.models.Item.objects.annotate(keywords=total), or(keywords absent)for a call passing no keyword), and a project passing only those staysnot_affected. - Keys the engine cannot establish — a
**extraparameter, a mapping built elsewhere or returned by a function,dict(zip(...)),**request.GET— leave it pending review, as any unknown argument does: the call is reachable, and exploitable when the mapping's values carry attacker input.
Keys passed through a project wrapper stay pending, and mapping.update(other),
mapping.setdefault(k, v), for k in mapping and mapping.keys() carry no key
taint. A literal the function fills after building it (m = {}; m[alias] = expr)
has keys the literal does not write, so the call is not ruled out.
A sequence condition says the flaw spans two calls on one receiver: a first call
leaves state on the object, and the entry point — the second call — reuses it, as
Session.get(url, verify=False) disables certificate verification for the pooled
connection a later Session.get(other) on the same session reuses (requests
CVE-2024-35195). It names the method the first call must be, by canonical name or
.-suffix, and, optionally, the argument that call must carry, read as an argument
condition reads it:
{
"kind": "sequence",
"text": "a prior call on the same session disabled certificate verification",
"method": "Session.get",
"argument": "verify",
"values": ["False"]
}The engine follows a receiver bound once in the analysed function to the result of a
call or a with and used only to call its methods. The outcome has three cases:
- A prior call on the same receiver, on every path to the entry call, matches the
method and carries a value in
values(defaultfor an absent argument): the condition is met, and the evidence names that call's line. - The receiver's whole lifetime is visible and no such prior call can precede — one
call alone on the receiver, or the two calls on different receivers: the condition
is contradicted, the call is ruled out
(
app.fetch:6 python.requests.Session.get(no prior Session.get on the receiver)) and a project with only such calls staysnot_affected. - Otherwise the condition stays pending review: a receiver the engine cannot follow —
a parameter, a module-level instance, one passed to another function or mutated
through an attribute such as
s.verify = False— a prior call whose arguments a*/**unpacking leaves undecided, or a flow reaching the entry point inside a callee.
Only a prior call on every path to the entry call counts as before it: a matching call in a branch that may not run, or a textually later one, leaves the condition pending, since a loop may bring it before the entry call. A call that itself carries what the condition requires of the first call is never ruled out either: across the iterations of a loop it is its own prior.
Every dependency finding records the highest level of evidence established in its
level metadata: declared (the requirement allows a vulnerable version), imported
(a module of the package is imported somewhere), reachable (an entry point or affected
symbol is called, or an entry point marked read is read) or exploitable (attacker
input reaches it). The last two carry
entry_point, justification, conditions, and which of them the call meets
(conditions_met) or leaves to review (conditions_pending_review). A requirement whose
entry points are only called with arguments that contradict a condition stays
imported, and its finding lists those calls in ruled_out
(app.views:12 python.yaml.load(Loader=python.yaml.SafeLoader)), the evidence that
they do not reach the vulnerability.
A coretrace-policy.toml at the root, or the file passed with --policy, denies
packages, requires pins and lists accepted advisories whose findings are dropped:
[dependencies]
deny = ["pycrypto"]
require_pinned = true
[advisories]
ignore = ["CVE-2020-1747"]--sbom PATH writes a CycloneDX 1.5 bill of materials: one component per requirement
with its package URL, and the advisories affecting them as vulnerabilities. Its tools
list the analyzer and the components of the check, each with its SHA-256 hash: a plugin
as an application, an advisory file as data.
coretrace-python-analyzer --check src/ --sbom sbom.json --policy security/policy.toml--vex PATH writes an OpenVEX 0.2.0 document with one statement per advisory affecting
a requirement. In each statement, the required package is a subcomponent of the project,
identified as pkg:generic/<directory>. The status follows from the evidence of the
check, including findings the policy accepts or a comment suppresses:
| Status | When |
|---|---|
affected |
The project's code reaches the vulnerable code. The notes give each place and its level, reachable or exploitable. |
not_affected |
Justified as vulnerable_code_not_in_execute_path, only when all of these hold: the advisory names entry points, no code of the project reaches them, every file and function was analysed, every template the project names was read if a template filter is among them, and a lock file (uv.lock or poetry.lock) shows that no other package requires the vulnerable one. The impact statement names the entry points and any calls ruled out by their arguments. |
under_investigation |
Anything else. The notes say which condition failed. |
A template the analyzer cannot read may apply any filter. The project names one when
render, render_to_string, TemplateResponse, an {% include %} or an {% extends %}
names a template by an expression, or by a name found under no templates directory,
including one an installed package ships; a template file that cannot be read counts
too, and so does a template loading a tag library the analyzer cannot read, or find,
or finds in several apps registering different filters, where it then applies a filter
of a built-in's name, since the library may replace it. An advisory whose entry points
include a template filter then stays under_investigation, and the notes list where
the project names each such template (app.views:12 names a template by an expression, app/templates/app/profile.html:1 loads 'crispy_forms_tags', not found).
A template named elsewhere, such as a class-based view's template_name, is not
checked.
not_affected needs the lock-file check because the analyzer reads the project's code,
not the code of installed packages. A framework calling the vulnerable function on the
project's behalf would go unseen. Without a lock file, a package is never
not_affected. The document's @id derives from its statements, so two runs with the
same result share it; its author is Unknown Author, as OpenVEX tools write when they
cannot know it. Its tooling names the analyzer and each component with its digest
(coretrace-python-analyzer 0.25.0; curated-advisories 2026.09.25 (sha256:…)), so the
@id changes with the advisory data a statement was decided with.
--cache DIR keeps the results of a directory check on disk, one entry per module,
keyed by the module's source, the tool version, every file of every plugin directory
(code, manifest and data alike), the security models, the advisories, the dependency
graph and the modules it imports. On the next run an unchanged
module is served from the cache, so editing one file re-analyses that file and its
importers only. Entries are plain data; an unreadable entry is recomputed.
--jobs N (-j N) analyses independent modules in N processes; 0 takes one per core
the process may run on, as runtime.jobs of ctrace does (its CPU affinity, read from
Python 3.13 on, and on Linux before; the cores of the machine otherwise), and the default
is one. On Windows, a pool holds at most 61 processes, the limit of Python's process pool
there, whatever N.
Modules are scheduled imports first, so the result is the same whatever N: the report,
the order of its findings, --fail-on, the cache, the baseline and the SBOM and VEX
documents. The command always waits for the complete report. There is no --async
option: ctrace deprecated its own for -j/--jobs, which this follows.
coretrace-python-analyzer --check src/ --cache .coretrace --jobs 4Measured on one core of a laptop, single process, cold start, with the bundled plugins (September 2026):
| Project | Python lines | Functions | Time | Peak memory |
|---|---|---|---|---|
| healthchecks (Django application) | 45 000 | 2 566 | 7 s | 107 MB |
| rich (library) | 52 000 | 1 198 | 6 s | 106 MB |
| wagtail (Django CMS) | 273 000 | 12 146 | 85 s | 461 MB |
Time grows a little faster than linearly with the size of the project, because the
interprocedural summaries are iterated to a fixpoint over the module graph. A warm
--cache brings an unchanged healthchecks to about one second. The
healthchecks repository is part of the regression corpus, so the analysis time of a
real 45 000-line project is exercised on every change.
--jobs pays when a wave of the module graph holds many components: each process
prepares the project once and reads the summaries of the modules a component imports
from a directory of finished modules, the cache or one of the run's own, at the cost of
one process's memory per job. Measured on a laptop with 8 cores (4 performance, 4
efficiency), macOS, Python 3.14, bundled plugins, cold start (October 2026):
| Project | Python files | -j 1 |
-j 8 |
|---|---|---|---|
| healthchecks | 653 | 15.3–15.5 s | 7.9–9.2 s |
| mitmproxy | 583 | 51.6 s | 45.6 s |
The modules of a strongly connected import cycle are analysed together by one process, and a wave waits for its slowest component: a project whose cycles are large gains less.
The package ships 35 plugins, loaded by default. Security models for the standard library
and the supported frameworks: python-stdlib-models, flask-models, fastapi-models,
django-models, aiohttp-models, tornado-models, bottle-models, sqlalchemy-models, db-driver-models (aiopg,
asyncpg, psycopg2, PyMySQL), http-client-models, credential-models and cli-models. Taint detectors: command-injection, sql-injection, path-traversal,
ssrf, xss, insecure-deserialization, open-redirect and plaintext-credentials.
Dangerous API detectors: dangerous-eval, weak-crypto, flask-debug,
missing-timeout, insecure-tls, unsafe-xml, unsafe-archive-extraction,
insecure-temp-file and weak-random. Secret scanners: hardcoded-secrets for Python sources and
config-secrets for configuration files. Dependency checks: sample-advisories,
vulnerable-dependency, reachable-vulnerability and dependency-policy.
--plugins DIR loads your own plugins on top, --no-bundled-plugins runs without the
shipped ones. Writing a plugin, a model for another framework or a detector for another
rule, is described in plugins.md.
coretrace-python-analyzer --check src/ --plugins ./coretrace-plugins
coretrace-python-analyzer --check src/ --no-bundled-plugins --plugins ./coretrace-pluginsA code base that already validates its input has functions the analyzer cannot know are
safe: a clean() that returns its argument looks, from its body, like a function that
passes taint through. Declare them in a model plugin by their project symbol,
python.<module>.<function>, and the analyzer takes your word over what it derived:
from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import Sanitizer, TaintKind, Validator
class HouseModels(ModelPlugin):
name = "house-models"
models = (
# the result of app.validation.clean is safe for commands, whatever the body does
Sanitizer(SymbolId("python.app.validation.clean"), TaintKind.COMMAND),
# a flow behind ``if app.validation.is_slug(value):`` is refuted
Validator(SymbolId("python.app.validation.is_slug")),
)The exit status gates the job and the SARIF report feeds code scanning. On GitHub Actions:
- run: pip install coretrace-python-analyzer
- run: coretrace-python-analyzer --check src/ --fail-on high --format sarif > coretrace.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: coretrace.sarifAdd --cache on a directory restored between runs to analyse only what changed.
from pathlib import Path
from coretrace_python import engine
analysis = engine.analyze_project(Path("src"), [engine.BUNDLED_PLUGINS])
for finding in analysis.findings:
print(finding.rule_id, finding.span.source_id, finding.span.start_line, finding.message)
print(analysis.coverage.summary())analyze_project accepts the same options as the command line (cache, jobs,
advisory_files, policy_file). analyze_file checks one SourceFile loaded through
SourceManager.
Inside functions: parameters with defaults, keyword-only and star forms, decorators,
assignments to names, attributes, items and unpacked tuples, augmented and chained
assignment, list, tuple, set and dict literals with unpacking, f-strings, slices,
boolean operators, chained comparisons, keyword and starred arguments, with, assert,
try/except/else/finally, await, yield, if, while and for with their
else clauses, break, continue, raise with from, conditional expressions,
comprehensions, lambdas, nested functions and classes, global, nonlocal, del,
assignment expressions ((y := f(x)), laid out as an assignment before the statement),
yield from, starred assignment targets (first, *rest = items), class keyword arguments
such as metaclass=, with targets of any assignable form and match with every
pattern kind. An assignment expression in an annotation binds its name as Python 3.11 to
3.13 do, where the annotation stands: in a function, the name is a local whether the
annotation is evaluated or not; at module level, it exists only once the annotation is
evaluated, so under from __future__ import annotations a builtin it names stays the
builtin. finally is modelled on the normal path only. A
nonlocal write made by a nested function flows back to the enclosing variable when the
enclosing function calls that nested function directly by name; a nested function passed
around and called elsewhere keeps its writes to itself. Where the nested function may
return without writing the name (no write on some path, a write constant propagation
proves cannot run, or an exception before it), the variable keeps the value it had
before the call, joined with what may be written; only a write on every path replaces it. Methods of module-level classes are
analysed like functions.
coretrace-python-analyzer --emit-ir example.py
coretrace-python-analyzer --emit-ir --ssa example.pyEach function is printed as its control-flow graph, one block per basic block ending in
an explicit terminator. With --ssa, locals become numbered values and merges get phi
instructions. Names are resolved to canonical symbols through imports and builtins, so
from os import system as run still shows a call to python.os.system.