Skip to content

Latest commit

 

History

History
995 lines (853 loc) · 67.9 KB

usage.md

File metadata and controls

995 lines (853 loc) · 67.9 KB

Using CoreTrace Python Analyzer

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.

Install

pip install coretrace-python-analyzer

Python 3.11 or later. The command coretrace-python-analyzer is installed with the package; python -m coretrace_python is equivalent.

Quick start

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.

Command line

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.

What is analysed

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.

Rules

Findings carry a rule id, a severity (critical, high, medium, low) and a confidence (high, medium, low).

Taint flows

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, tuple or list of one of these;
  • a module-level tuple, or frozenset of a display, of constants, bound once in its module and never rebound there, by another assignment, an augmented assignment or a global statement.

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 templates directory, by the path Django uses (app/page.html), and everything those templates extend or include.
  • None of them marks output safe: |safe, |safeseq or {% 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="…"), a style attribute, a URL attribute not fixed by a relative or http(s)://host/ prefix, an unquoted attribute, or an attribute name.
  • No Python file of the project sets autoescape to False.

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.

Dangerous API usage

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.

Secrets

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.

Dependencies

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.

Suppressing a finding

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.

Adopting the analyzer on an existing code base

A repository with known findings starts from a baseline:

coretrace-python-analyzer --check src/ --baseline coretrace-baseline.json

The 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.

Reports

--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.sarif

Dependencies, advisories and policy

The 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.json

A 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 TEMPLATES a literal list at module level, every DjangoTemplates engine of it lists the processor in a literal context_processors, and no other code of the project names the module's TEMPLATES, or a TEMPLATES it does not bind, as a star import gives. A TEMPLATES bound 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: render and TemplateResponse always do, render_to_string when given request, SimpleTemplateResponse never.
  • The render's context is certain, absent or a dict literal as above, so it cannot hide a request entry. 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, COOKIES or headers; not user or session.

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 assignment mapping[alias] = expr, and copied by {**mapping} and dict(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 stays not_affected.
  • Keys the engine cannot establish — a **extra parameter, 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 (default for 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 stays not_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.

Large projects

--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 4

Performance

Measured 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.

Plugins

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-plugins

Your own validation layer

A 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")),
    )

Continuous integration

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.sarif

Add --cache on a directory restored between runs to analyse only what changed.

From Python

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.

Supported syntax

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.

Looking at the intermediate representation

coretrace-python-analyzer --emit-ir example.py
coretrace-python-analyzer --emit-ir --ssa example.py

Each 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.