diff --git a/README.md b/README.md index fa0ccd4..c13ce56 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,27 @@ # CoreTrace Python Analyzer -A standalone, Python-specific static analysis frontend for CoreTrace. +A standalone static security analyzer for Python. It finds injection vulnerabilities by +following attacker-controlled data through the program, across functions, files, objects +and closures, and judges each flow against the guards on its path; it reports dangerous +API usage, secrets committed in sources and configuration, and vulnerable or forbidden +dependencies, correlated with the code that reaches them. It runs offline, on a file or a +whole project, with no runtime dependency. -The analyzer loads source through a source manager, adapts parsed Python into a -parser-independent high-level representation (PyHIR), and lowers a deliberately small -language subset to deterministic PyIR. CFG construction, SSA, data-flow, and security rules -are intentionally deferred. - -```text -Python source -> SourceManager -> frontend -> PyHIR -> semantic imports and scopes -> PyIR +```bash +pip install coretrace-python-analyzer +coretrace-python-analyzer --check src/ --format sarif > report.sarif ``` -The long-term engine architecture and incremental migration plan are recorded in -[`docs/architecture.md`](docs/architecture.md). +- [Usage guide](docs/usage.md): command line, rules, report formats, dependencies and + advisories, cache and parallelism, continuous integration. +- [Writing a plugin](docs/plugins.md): models for another framework, detectors for + another rule, secret patterns and project-wide checks. +- [Architecture](docs/architecture.md): the engine's design and its migration plan. + +The pipeline: source manager, parser-independent high-level representation (PyHIR), +semantic resolution of imports and scopes, lowering to a small intermediate +representation (PyIR), control-flow graphs, SSA, data-flow and abstract interpretation, +interprocedural summaries, taint and refutation, then plugins and reporters. ## Development @@ -34,193 +43,3 @@ python -m pytest -m regression CORETRACE_REGRESSION_UPDATE=1 python -m pytest -m regression # record an intended change ``` -## Install - -```bash -pip install coretrace-python-analyzer -``` - -The package has no runtime dependency and ships its plugins: security models for the -standard library and the supported frameworks, the taint detectors, the secret scanners -and the dependency checks are loaded by default. `--plugins DIR` adds a directory of your -own plugins on top, `--no-bundled-plugins` runs without the shipped ones. - -## Check a file or a project - -```bash -coretrace-python-analyzer --check app.py -coretrace-python-analyzer --check src/ --format sarif > report.sarif -coretrace-python-analyzer --check src/ --plugins my_plugins/ --cache .coretrace --jobs 4 -``` - -Given a directory, every Python file below it is analysed as one project: modules are -named after their packages, and taint follows calls into functions defined in other files -through a project-wide index of function summaries iterated to a fixpoint. Hidden and -tooling directories (`.venv`, `node_modules`, `__pycache__`, `build`, `dist`) are skipped, -and so is any virtual environment, recognised by its `pyvenv.cfg` whatever its name. A -module or package whose name is not an identifier is analysed like any other. -The dependency files at the root (`requirements*.txt`, `pyproject.toml`, `poetry.lock`, -`uv.lock`) are resolved into a dependency graph. Plugins may contribute advisories; the -shipped `dependency/` plugins report a requirement that allows a vulnerable version at -its line, and every call in the project to an API the advisory affects. When -attacker-controlled data reaches such a call, the engine correlates the four facts into -one critical `exploitable-vulnerability` finding, judged like any other flow. The shipped -advisory database is a small offline sample. A real feed stays offline too: -`--import-advisories SRC OUT` converts a public OSV dump (a JSON file, a directory of -them or a zip archive) into a local advisory file once, and a directory check reads -`advisories.json` at the project root plus the files passed with `--advisories`, the -local file winning over a plugin for the same advisory. OSV records name no affected -APIs, so imported advisories feed the requirement checks and the SBOM; add -`affected_symbols` by hand to a local entry and it feeds reachability and correlation -too. A `coretrace-policy.toml` at the root, or the file passed with `--policy`, denies -packages (`denied-dependency`), requires pins (`unpinned-dependency`) and lists accepted -advisories whose findings are dropped: - -```toml -[dependencies] -deny = ["pycrypto"] -require_pinned = true - -[advisories] -ignore = ["CVE-2020-1747"] -``` - -`--sbom PATH` writes a CycloneDX 1.5 bill of materials of the dependency graph, one -component per requirement with its package URL, and the advisories affecting them as -vulnerabilities. The shipped `secrets/` plugin scans every string literal of the Python -sources for provider-specific secret formats (AWS, GitHub, Slack, Stripe, Google, private -keys, JWTs, SendGrid, Twilio), for credential-like names bound to a real value -(`password`, `token`, `api_key` and the like, bound by name, attribute or constant key -such as `app.config['SECRET_KEY']`, placeholders excluded) and for opaque -high-entropy tokens; one finding per literal, at high, medium and low confidence -respectively, with a redacted preview and never the secret itself. The `config-secrets` -plugin applies the same rules to the key-value pairs of `.env`, YAML, TOML, JSON, INI and -properties files under the project root. Other plugins add providers by subclassing -`SecretDetector` with their own patterns. - -`--cache DIR` keeps the results of a directory check on disk, one JSON entry per module. -The entry is keyed by the module's source, the engine and plugin versions, the plugin -code, the security models, the advisories, the dependency graph and the keys of the -modules it imports transitively. On the next run a module whose key is unchanged is -served from the cache: its function summaries seed the project index, its call sites -serve the project plugins and its findings are reported as they were, so editing one file -re-analyses that file and the modules that import it only. Entries are plain data, never -code; an unreadable entry is simply recomputed. - -Modules are analysed by strongly connected components of the module graph, imports -first, so a module starts with the final summaries of everything it imports and mutually -importing modules are iterated together. `--jobs N` analyses the components of one wave -in `N` processes; each worker rebuilds the configuration from the project root and the -plugin roots and hands back summaries, call sites and findings as plain data, so the -result is the same whatever `N`. Once a module's results are extracted, its intermediate -representation and derived results are dropped and only its semantic tables stay in -memory. - -`--plugins` is a directory searched recursively for `plugin.toml` manifests and may be -repeated. The package ships its set under `coretrace_python/bundled/`: security models for the -standard library, Flask, FastAPI, Django, SQLAlchemy, the Requests and httpx clients, and the -click and Typer command-line frameworks (`models/`), taint detectors for SQL -injection, command injection, path traversal, SSRF, XSS, insecure deserialization, open -redirects and plaintext credential storage (`security/`), and syntactic -detectors for `eval`/`exec`, weak hashes, `app.run(debug=True)` and HTTP client calls -without a timeout (`syntax/`). Route handlers of Flask and -FastAPI receive their parameters as HTTP input, whether the application is created -directly (`app = Flask(__name__)`) or by a project factory whose summary returns it -(`app = create_app()`); `request.args` and its siblings are HTTP -sources; the parameters of a click or Typer command are `argv` input. SQL sinks read the -statement only, so a tainted value in the parameter tuple of a parameterised query is not an -injection, and an annotated parameter denotes its class, so `db: Session = Depends(get_db)` -gives `db.execute` its sink. A parameter named `password` or the like is a credential wherever it appears: stored -in a database without passing through a hashing function it is a -`plaintext-credential-storage` finding at medium confidence, since a name is a hint. -Django views are undecorated, so a parameter annotated with a request class -(`request: HttpRequest`), a method of a class-based view and a view decorated by one of -Django's or Django REST framework's view decorators receive HTTP input, and so does a -view registered in a URL configuration (`path('login/', views.log_in)`, `as_view()`, -REST framework routers) or programmatically (`add_url_rule`, `add_api_route`): the -engine reads every module's registrations before analysing. `Model.objects.raw` and -`extra` are SQL sinks for any model class. Requests and httpx calls are SSRF sinks whose responses are untrusted -`http-response` sources. Model plugins contribute sources, sinks and sanitizers; -detectors consume the shared taint result, so adding a framework model makes every -detector aware of it. Taint follows calls between functions of the same module through -function summaries: a tainted argument passed to a helper that reaches a sink is reported -at the call site, and a helper returning attacker-controlled data taints its result. -Taint also follows objects, not only values: a coarse heap abstraction gives every -allocation site one abstract object with an `elements` and an `attributes` location, so -`b = a; b.append(user_input); sink(a[0])`, dictionary and attribute stores, `extend`, -`insert`, `add`, `update` and iteration all carry taint through the container. Function -summaries record the parameters a function mutates and the module globals it touches, so -a helper that fills a list taints the caller's list, across files too. -Objects of the project's own classes are followed too: `App(x)` calls `App.__init__` on -the new object, `app.run()` and `self.run()` call the class's methods with the receiver as -`self`, so a value stored in one method and read in another flows through the object, -across files through the project index. A method the framework calls, a Django view's -`get` for instance, starts with what its sibling methods store into `self` from their -own inputs. -Each flow is then judged: a dominating guard that proves the value safe (`isdigit()`, -membership in a constant allowlist, equality with a constant) refutes it and it is not -reported; a guard that only mentions the value makes it a hotspot with medium confidence; -the verdict and its evidence are in the finding metadata. Every check ends with a -coverage line, `coverage: 3/4 files, 5/6 functions`, and the JSON report carries the -per-file detail, so "no findings" can be told from "nothing analysed". Three more kinds of evidence -apply: a value proven numeric by the range analysis (`int()`, `len()`, arithmetic, -bounded by the comparisons on the path) cannot inject; a `Validator` model names a -callable whose truth proves one of its arguments, such as `re.fullmatch`; and an -`AuthorizationGuard` model names a decorator or a condition that restricts who reaches -the code, such as `login_required`, behind which a flow is a hotspot rather than a -vulnerability. Plugins contribute validators and authorization guards through their -models, like sources and sinks. `--format` is `text` (default), `json` or `sarif`. Exit status is 0 when nothing -was found, 1 when findings were reported, and 2 on a usage or analysis error. - -## Emit PyIR - -```bash -coretrace-python-analyzer --emit-ir example.py -# or, without installing: -PYTHONPATH=src python -m coretrace_python --emit-ir example.py -``` - -Currently supported inside functions: parameters with defaults and keyword-only or star -forms, decorators, assignments to names, attributes, items and unpacked tuples, augmented -assignment, list, tuple and dict literals with `*` and `**` unpacking, f-strings, slices, -`and`/`or`, chained comparisons, keyword and starred arguments, `with`, `assert`, -`try`/`except`/`else`/`finally`, `await`, `yield`, `if`/`elif`/`else`, `while`, `for` -with name or tuple targets, `break`, `continue` and `raise`, `from` clause included, -conditional expressions and comprehensions, laid out as real branches and loops over a -synthetic local, set literals, chained assignments, assignments to `global` names, -lambdas and nested function definitions, analysed as functions of their own whose -captured variables are implicit parameters so taint flows through closures like through -any call, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard, -or, sequence, mapping and class patterns with guards, laid out as an `if` chain over -length, index, membership, attribute and `isinstance` tests, positional class patterns -using the class's `__match_args__` or a dataclass's field order when the class is in the -module; `nonlocal` assignments, whose writes stay inside the nested function; classes -defined inside functions, whose methods are analysed as nested functions; and control flow -inside a `while` condition, recomputed every iteration. Left: a function using syntax -the frontend rejects is reported as a `syntax-error` for its file. Blocks inside a `try` -body carry exception edges to the handlers; `finally` is modelled on the normal path only. Methods of module-level classes are analysed like functions; other module-level -code is skipped. An import inside a function is shown where it runs, as an `import` -instruction naming the module, the bound name and the canonical symbol. A function using syntax outside this subset is reported by `--check` as -an `unsupported-syntax` note and the other functions are still analysed. Each function is -emitted as its control-flow graph: one block per basic block, ending in an explicit -terminator (`branch`, `jump`, `return`, `raise`, `for_next`). Add `--ssa` to print the -static single assignment form: locals become numbered values, merges get `phi` -instructions and unassigned paths read an explicit `undefined` value. -Unsupported syntax produces a source-located diagnostic and a non-zero exit status. - -Names are resolved to canonical symbols through lexical scopes, imports and builtins. -Calling a symbol denotes that symbol, so `app = Flask(__name__)` makes `app.route` resolve -to `python.flask.Flask.route` and `sqlite3.connect(p).cursor().execute` resolves to -`python.sqlite3.connect.cursor.execute`; model plugins register those chains. -Import aliases do not change an API's identity, so all of the following resolve to -`python.os.system`: - -```python -import os -from os import system -from os import system as run -``` - -Relative imports resolve against the module's dotted name, derived from the enclosing -`__init__.py` packages. Builtins resolve to `python.builtins.`. Names introduced by -wildcard imports stay unresolved and are emitted as `global`. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..2013e3b --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,228 @@ +# Writing a plugin + +A plugin is a directory holding a `plugin.toml` manifest and a Python module with one +class. The analyzer searches `--plugins DIR` recursively for manifests, imports each +module by path and instantiates the class. The plugins shipped with the package live +under `coretrace_python/bundled/` and are written the same way; they are the reference +for every kind of plugin described here. + +```text +coretrace-plugins/ +└── sensitive_logging/ + ├── plugin.toml + └── sensitive_logging.py +``` + +```bash +coretrace-python-analyzer --check src/ --plugins ./coretrace-plugins +``` + +## The manifest + +```toml +name = "sensitive-logging" +version = "1.0.0" +plugin_api = ">=1,<2" +requires = ["ir.ssa"] +provides = ["vulnerability.sensitive-logging"] + +[entrypoint] +module = "sensitive_logging" +class = "SensitiveLoggingPlugin" +``` + +| Field | Meaning | +|---|---| +| `name` | Unique plugin name, also used in cache keys. | +| `version` | The plugin's own version; changing it invalidates cached results. | +| `plugin_api` | The range of plugin API versions the plugin supports. The current API is version 1. | +| `requires` | Capabilities the plugin needs; a mismatch with the class's `requires` is a manifest error. | +| `provides` | Capabilities the plugin offers, for documentation and conflict detection. | +| `entrypoint` | The module file (without `.py`, next to the manifest) and the class in it. | + +## Symbols + +Every API is named by a canonical symbol: `python.` followed by the dotted path, +resolved through imports and aliases, so `python.os.system` covers `os.system`, +`from os import system` and `from os import system as run`. Calling a symbol denotes that +symbol, so a chain such as `sqlite3.connect(p).cursor().execute` is +`python.sqlite3.connect.cursor.execute`, and `app = Flask(__name__)` gives `app.route` the +symbol `python.flask.Flask.route`. Builtins are `python.builtins.`. Symbols are +`SymbolId` values from `coretrace_python.semantic.symbols`. + +## Kinds of plugin + +Every plugin is a `Plugin` subclass (`coretrace_python.plugins`) with a class-level +`name` and a `requires` set of analyses; the base classes below fill in `analyze` for the +common cases. + +### A detector of calls: `SymbolCallDetector` + +Reports every call to one of a set of symbols, whatever name the file gives them. + +```python +from typing import ClassVar + +from coretrace_python.findings import Severity +from coretrace_python.plugins import SymbolCallDetector +from coretrace_python.semantic.symbols import SymbolId + + +class SensitiveLoggingPlugin(SymbolCallDetector): + name: ClassVar[str] = "sensitive-logging" + rule_id: ClassVar[str] = "sensitive-logging" + symbols: ClassVar[frozenset[SymbolId]] = frozenset({SymbolId("python.logging.debug")}) + severity: ClassVar[Severity] = Severity.LOW + message_template: ClassVar[str] = "call to {symbol} may log sensitive data" +``` + +The shipped `dangerous-eval` and `weak-crypto` plugins are of this kind. + +### A detector of taint flows: `TaintDetector` + +Reports the flows of one taint kind that reach a sink and survive refutation. The +sources, sinks and sanitizers come from the model plugins, so a detector stays generic +and a new framework model makes every detector aware of it. + +```python +from typing import ClassVar + +from coretrace_python.findings import Severity +from coretrace_python.plugins import TaintDetector +from coretrace_python.taint import TaintKind + + +class CommandInjectionPlugin(TaintDetector): + name: ClassVar[str] = "command-injection" + rule_id: ClassVar[str] = "command-injection" + kind: ClassVar[TaintKind] = TaintKind.COMMAND + severity: ClassVar[Severity] = Severity.HIGH + title: ClassVar[str] = "Command injection" +``` + +`confidence` defaults to high; a hotspot, a flow behind a guard that mentions the value +or an authorization decorator, is reported at medium. The taint kinds are `SQL`, +`COMMAND`, `HTML`, `PATH`, `SSRF`, `CODE`, `DESERIALIZATION`, `REDIRECT`, `ADVISORY` +(APIs affected by advisories) and `CREDENTIAL`, which only credential-named parameters +carry so that ordinary input reaching a database write is not a plaintext credential. + +### Security models: `ModelPlugin` + +Declares sources, sinks and sanitizers for a library or a framework. A model plugin +reports nothing by itself; the detectors consume what it declares. + +```python +from typing import ClassVar + +from coretrace_python.plugins import ModelPlugin +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.taint import EntryPoint, Model, Sanitizer, Sink, Source, TaintKind + + +class BottleModels(ModelPlugin): + name: ClassVar[str] = "bottle-models" + models: ClassVar[tuple[Model, ...]] = ( + EntryPoint(SymbolId("python.bottle.route"), "http"), + Source(SymbolId("python.bottle.request.query"), "http"), + Sink(SymbolId("python.bottle.redirect"), TaintKind.REDIRECT), + Sanitizer(SymbolId("python.bottle.html_escape"), TaintKind.HTML), + ) +``` + +| Model | Declares | +|---|---| +| `Source(symbol, label, kinds=ALL)` | A call or attribute whose value is attacker-controlled; `label` names it in messages (`http`, `stdin`, `argv`, `http-response`). `kinds` restricts what it can inject. | +| `Sink(symbol, kinds, positions=())` | A call dangerous for `kinds`; `positions` limits a kind to given argument indexes, as SQL sinks read the statement only. | +| `Sanitizer(symbol, kinds)` | A call whose result is safe for `kinds`. | +| `EntryPoint(symbol, label, kinds=ALL)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`. | +| `TypedParameter(symbol, label, kinds=ALL)` | A class whose annotated parameters carry `label` input, such as Django's `HttpRequest`. | +| `NamedParameter(pattern, label, kinds)` | A regular expression on parameter names; matching parameters carry `kinds`, as `password` carries `CREDENTIAL`. | +| `RouteRegistrar(symbol, argument, label, kinds=ALL, keyword=None)` | A call registering a view at argument `argument` (or `keyword`), such as Django's `path`; the view receives `label` input. | +| `SuffixSink(suffix, kinds, positions=())` | A sink matched by the end of the symbol, for methods of any class such as `objects.raw`. | +| `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe, such as `re.fullmatch`; a flow guarded by it is refuted. | +| `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. | + +A model plugin may also carry `advisories`, a tuple of `Advisory` values +(`coretrace_python.dependency`) with the package, the vulnerable range and the affected +symbols, as the shipped `sample-advisories` plugin does. Requirements matching them are +reported, calls to the affected symbols become reachable vulnerabilities and tainted +calls become exploitable ones. + +### Secret scanners: `SecretDetector` + +Judges every string literal of the module, or every value of a configuration file, +against provider patterns, credential-like names and entropy. + +```python +from typing import ClassVar + +from coretrace_python.plugins import SecretDetector, SecretPattern +from coretrace_python.plugins.secrets import DEFAULT_CREDENTIAL_NAMES, DEFAULT_PATTERNS + + +class InternalSecrets(SecretDetector): + name: ClassVar[str] = "internal-secrets" + patterns: ClassVar[tuple[SecretPattern, ...]] = ( + *DEFAULT_PATTERNS, + SecretPattern("acme", r"acme_[0-9a-f]{40}"), + ) + credential_names: ClassVar[tuple[str, ...]] = (*DEFAULT_CREDENTIAL_NAMES, "acme_key") +``` + +`entropy_threshold`, `hex_entropy_threshold` and `minimum_length` tune the high-entropy +rule. Findings carry a redacted preview only. + +### Project-wide checks: `ProjectPlugin` + +Runs once per project after every module is analysed, with a `ProjectContext`: the +module graph, the dependency graph, every advisory the plugins and advisory files +contributed, the policy, the project root, and each module's imports and call graph. The +shipped `vulnerable-dependency`, `reachable-vulnerability` and `dependency-policy` plugins +are of this kind. + +```python +from collections.abc import Sequence +from typing import ClassVar + +from coretrace_python.findings import Finding +from coretrace_python.plugins import ProjectContext, ProjectPlugin + + +class RequirementsPresent(ProjectPlugin): + name: ClassVar[str] = "requirements-present" + + def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]: + return () # inspect ctx.dependencies, ctx.modules(), ctx.call_graph(module) +``` + +### Anything else: `Plugin` + +Override `analyze(self, ctx: PluginContext)` and return findings. `ctx.module` is the +module, `ctx.functions` its analysable functions and `ctx.get(Analysis, function)` +any analysis result: `SSAAnalysis` (`coretrace_python.ir.ssa`) for the code, +`TaintAnalysis` (`coretrace_python.taint`) for the flows, `RefutationAnalysis` +(`coretrace_python.findings.refutation`) for their verdicts, `DependencyAnalysis` +(`coretrace_python.dependency`) for the requirements. List what you read in `requires`. + +## Findings + +A `Finding` (`coretrace_python.findings`) has a `rule_id`, a `message`, a `Severity`, a +`Confidence`, a `SourceSpan` (file, start and end line and column), the enclosing +`function` and string `metadata`. Rule ids are lower-case words joined by dashes. + +## Testing a plugin + +Load the plugin directory with the engine and check one source or one project: + +```python +from pathlib import Path + +from coretrace_python import engine +from coretrace_python.source import SourceManager + +source = SourceManager().add_source("app.py", "import logging\n\ndef f(x):\n logging.debug(x)\n") +findings = engine.check(source, [Path("coretrace-plugins")]) +assert [f.rule_id for f in findings] == ["sensitive-logging"] +``` + +Pass `engine.BUNDLED_PLUGINS` too when the plugin relies on the shipped models. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..f429447 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,322 @@ +# 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 + +```bash +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 + +```bash +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: + +```text +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. + +## Command line + +```text +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. | +| `--jobs N` | Analyse independent modules in `N` processes. | +| `--sbom PATH` | Write a CycloneDX bill of materials of the dependencies to `PATH`. | +| `--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. | +| `--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`, `--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`). 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; module-level statements outside functions are not. +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`. 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 and Django route handlers, class-based views and registered +URL patterns receive HTTP input; click and Typer commands receive `argv` input; `input()`, +`sys.argv`, environment variables and the responses of HTTP clients are further sources. + +## 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. + +| Rule | Reached sink | +|---|---| +| `command-injection` | Shell or process execution (`os.system`, `subprocess` with a string, …). | +| `sql-injection` | A database statement (`cursor.execute`, SQLAlchemy `text`, Django `raw`, …). Parameters of a parameterised query are not statements. | +| `path-traversal` | A file system path (`open`, `send_file`, `os.remove`, …). | +| `ssrf` | The URL of an HTTP client request (Requests, httpx, `urllib`). | +| `xss` | An HTTP response body built without escaping. | +| `insecure-deserialization` | `pickle`, `yaml.load` and similar loaders. | +| `open-redirect` | An HTTP redirect target. | +| `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. A dominating guard that proves the value +safe (`isdigit()`, membership in a constant collection, 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 guard that only mentions the value, +or an authorization decorator such as `login_required`, makes it a hotspot reported at +medium confidence. The verdict and its evidence are in the finding's metadata. + +### 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`. | + +### 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. | +| `high-entropy-string` | low | An opaque high-entropy token. | + +### 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. | +| `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. | + +## 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. + +`--format json` prints one document: + +```json +{ + "schema_version": 1, + "tool": {"name": "coretrace-python-analyzer", "version": "0.1.0"}, + "findings": [ + { + "rule_id": "sql-injection", + "message": "SQL injection: http input reaches python.sqlite3.connect.cursor.execute", + "severity": "high", + "confidence": "high", + "location": {"path": "src/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": "src/app.py", "status": "analysed", "functions": 9, "analysed": 9}] + } +} +``` + +`--format sarif` prints a SARIF 2.1.0 log, one run with the tool, its rules and one +result per finding, ready for code scanning services: + +```bash +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. + +```bash +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 and, optionally, the +affected APIs that feed reachability and correlation: + +```json +{ + "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.load", "python.yaml.full_load"], + "aliases": ["GHSA-8q59-q68h-6hv4"] + } + ] +} +``` + +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: + +```toml +[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. + +```bash +coretrace-python-analyzer --check src/ --sbom sbom.json --policy security/policy.toml +``` + +## 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 and plugin versions, 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` analyses independent modules in `N` processes. Modules are scheduled imports +first, so the result is the same whatever `N`. + +```bash +coretrace-python-analyzer --check src/ --cache .coretrace --jobs 4 +``` + +## Plugins + +The package ships 26 plugins, loaded by default. Security models for the standard library +and the supported frameworks: `python-stdlib-models`, `flask-models`, `fastapi-models`, +`django-models`, `sqlalchemy-models`, `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` and +`missing-timeout`. 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](plugins.md). + +```bash +coretrace-python-analyzer --check src/ --plugins ./coretrace-plugins +coretrace-python-analyzer --check src/ --no-bundled-plugins --plugins ./coretrace-plugins +``` + +## Continuous integration + +The exit status gates the job and the SARIF report feeds code scanning. On GitHub +Actions: + +```yaml +- run: pip install coretrace-python-analyzer +- run: coretrace-python-analyzer --check src/ --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 + +```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` and +`match` with every pattern kind. `finally` is modelled on the normal path only and a +`nonlocal` write stays inside the nested function. Methods of module-level classes are +analysed like functions. + +## Looking at the intermediate representation + +```bash +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`. diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..43b3637 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,99 @@ +"""Acceptance tests for the usage documentation. + +``docs/usage.md`` documents the command line: every option of the parser, every rule the +shipped plugins can report, the report formats and the exit codes. ``docs/plugins.md`` +documents how to write a plugin: the manifest, every base class and every model kind the +plugin API exports. The README points at both, and every command the guides show uses +options that exist. + +Expected to remain red until ``docs/usage.md`` and ``docs/plugins.md`` exist. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from coretrace_python import cli, engine, plugins, taint + +REPO = Path(__file__).resolve().parent.parent +USAGE = REPO / "docs" / "usage.md" +PLUGINS = REPO / "docs" / "plugins.md" +README = REPO / "README.md" + + +@pytest.fixture(autouse=True) +def require_docs() -> None: + missing = [path.name for path in (USAGE, PLUGINS) if not path.is_file()] + if missing: + pytest.fail(f"usage documentation is not written yet: {missing}") + + +def options() -> set[str]: + parser = cli.build_parser() + return { + option + for action in parser._actions + for option in action.option_strings + if option.startswith("--") + } + + +def shipped_rules() -> set[str]: + rules: set[str] = set() + for path in (REPO / "src" / "coretrace_python").rglob("*.py"): + text = path.read_text(encoding="utf-8") + rules.update(re.findall(r'rule_id[^"\n]{0,40}"([a-z-]+)"', text)) + rules.update(re.findall(r'Finding\(\s*"([a-z]+-[a-z-]+)"', text)) + rules.update(re.findall(r'_note\("([a-z-]+)"', text)) + return rules - {"message"} + + +def test_usage_documents_every_option_rule_format_and_exit_code() -> None: + text = USAGE.read_text(encoding="utf-8") + assert all(option in text for option in options()), options() - {o for o in options() if o in text} + assert shipped_rules() <= set(re.findall(r"`([a-z-]+)`", text)), shipped_rules() - set( + re.findall(r"`([a-z-]+)`", text) + ) + assert len(shipped_rules()) >= 20 + assert all(f"`{fmt}`" in text for fmt in ("text", "json", "sarif")) + assert "exit status" in text.lower() + + +def test_usage_commands_only_use_existing_options() -> None: + text = USAGE.read_text(encoding="utf-8") + commands = re.findall(r"^\s*coretrace-python-analyzer (.*)$", text, re.MULTILINE) + assert len(commands) >= 5 + for command in commands: + used = set(re.findall(r"(--[a-z-]+)", command)) + assert used <= options(), command + + +def test_plugin_guide_documents_the_manifest_base_classes_and_models() -> None: + text = PLUGINS.read_text(encoding="utf-8") + for field in ("name", "version", "plugin_api", "requires", "provides", "entrypoint"): + assert f"`{field}`" in text + for base in ("Plugin", "ModelPlugin", "ProjectPlugin", "SymbolCallDetector", "TaintDetector", "SecretDetector"): + assert base in plugins.__all__ and f"`{base}`" in text + for model in ("Source", "Sink", "Sanitizer", "EntryPoint", "TypedParameter", "NamedParameter", + "RouteRegistrar", "SuffixSink", "Validator", "AuthorizationGuard"): + assert hasattr(taint, model) and re.search(rf"`{model}\b", text), model + assert all(kind.name in text for kind in taint.TaintKind if kind.name not in {"NONE", "ALL"}) + assert "--plugins" in text and "plugin.toml" in text + + +def test_every_bundled_plugin_is_named_in_the_guides() -> None: + text = USAGE.read_text(encoding="utf-8") + PLUGINS.read_text(encoding="utf-8") + names = [ + re.search(r'^name = "([a-z-]+)"', manifest.read_text(encoding="utf-8"), re.MULTILINE).group(1) # type: ignore[union-attr] + for manifest in engine.BUNDLED_PLUGINS.rglob("plugin.toml") + ] + assert len(names) == 26 + assert all(f"`{name}`" in text for name in names), [n for n in names if f"`{n}`" not in text] + + +def test_readme_links_to_both_guides() -> None: + text = README.read_text(encoding="utf-8") + assert "docs/usage.md" in text and "docs/plugins.md" in text