Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,25 @@ jobs:
- run: python -m mypy
- run: python -m ruff check .
- run: python -m pytest

package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install build twine
- run: python -m build
- run: python -m twine check dist/*
- name: Install the wheel in a clean environment and run a check
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
printf 'def run(code):\n eval(code)\n' > /tmp/sample.py
/tmp/smoke/bin/coretrace-python-analyzer --check /tmp/sample.py --format json > /tmp/report.json || test $? -eq 1
grep -q '"rule_id": "dangerous-eval"' /tmp/report.json
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Release

# Publishes the package to PyPI when a version tag is pushed (``v0.1.0``). The tag must
# match the version in pyproject.toml. Publication uses PyPI trusted publishing: the
# ``pypi`` environment and the trusted publisher for this repository and workflow must be
# configured on pypi.org once; no token is stored.

on:
push:
tags: ["v*"]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check that the tag matches the package version
run: |
version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
test "v$version" = "${GITHUB_REF_NAME}"
- run: python -m pip install build twine
- run: python -m build
- run: python -m twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,23 @@ python -m pytest
python -m ruff check .
```

## Check a file with plugins
## Install

```bash
coretrace-python-analyzer --check app.py --plugins plugins/
coretrace-python-analyzer --check src/ --plugins plugins/ --format sarif > report.sarif
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
Expand Down Expand Up @@ -95,7 +107,7 @@ representation and derived results are dropped and only its semantic tables stay
memory.

`--plugins` is a directory searched recursively for `plugin.toml` manifests and may be
repeated. The repository ships a first set under `plugins/`: security models for the
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
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1057,10 +1057,12 @@ Refuted

# 25. Plugin Types

Possible organization:
Organization of the plugins shipped inside the package (`coretrace_python/bundled/`,
loaded by default; `--plugins DIR` adds external directories, `--no-bundled-plugins`
leaves them out):

```text
plugins/
bundled/
├── syntax/
│ ├── dangerous_eval/
│ └── weak_crypto/
Expand Down
22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,33 @@ readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [{ name = "CoreTrace" }]
keywords = ["static-analysis", "security", "taint", "sast", "python"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Security",
"Topic :: Software Development :: Quality Assurance",
]
dependencies = []

[project.urls]
Homepage = "https://github.com/CoreTrace/coretrace-python-analyzer"
Repository = "https://github.com/CoreTrace/coretrace-python-analyzer"
Issues = "https://github.com/CoreTrace/coretrace-python-analyzer/issues"

[project.optional-dependencies]
dev = [
"build>=1.2",
"mypy>=1.15",
"pytest>=8.0",
"ruff>=0.9",
"twine>=5.0",
]

[project.scripts]
Expand All @@ -40,3 +60,5 @@ known-first-party = ["coretrace_python"]
python_version = "3.11"
strict = true
files = ["src"]
# Bundled plugins are loaded by path, not imported as modules.
exclude = ["src/coretrace_python/bundled/"]
17 changes: 14 additions & 3 deletions src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ def build_parser() -> argparse.ArgumentParser:
type=Path,
default=[],
metavar="DIR",
help="with --check, a directory searched recursively for plugin.toml (repeatable)",
help="with --check, a directory searched recursively for plugin.toml, loaded on "
"top of the bundled plugins (repeatable)",
)
parser.add_argument(
"--no-bundled-plugins",
action="store_true",
help="with --check, do not load the plugins shipped with the package",
)
parser.add_argument(
"--format",
Expand Down Expand Up @@ -164,6 +170,11 @@ def main(argv: list[str] | None = None) -> int:
print("error: --policy only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR

if args.no_bundled_plugins and not args.check:
print("error: --no-bundled-plugins only applies to --check", file=sys.stderr)
return EXIT_ERROR
plugin_roots = ([] if args.no_bundled_plugins else [engine.BUNDLED_PLUGINS]) + list(args.plugins)

if args.path.is_dir() and args.emit_ir:
print(f"error: {args.path} is a directory; --emit-ir needs a file", file=sys.stderr)
return EXIT_ERROR
Expand All @@ -177,7 +188,7 @@ def main(argv: list[str] | None = None) -> int:
cache = None if args.cache is None else ProjectCache(args.cache)
analysis = engine.analyze_project(
args.path,
args.plugins,
plugin_roots,
cache=cache,
jobs=args.jobs or 1,
advisory_files=args.advisories,
Expand All @@ -191,7 +202,7 @@ def main(argv: list[str] | None = None) -> int:
encoding="utf-8",
)
else:
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), args.plugins)
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
findings, coverage = file_analysis.findings, file_analysis.coverage
print(render(args.format or "text", engine.report(findings, coverage)), end="")
return EXIT_FINDINGS if findings else EXIT_CLEAN
Expand Down
11 changes: 10 additions & 1 deletion src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@

TOOL_NAME = "coretrace-python-analyzer"

# The plugins shipped with the package: models, detectors, secrets and dependency
# checks. They are loaded by default; ``--plugins`` adds directories on top.
BUNDLED_PLUGINS = Path(__file__).resolve().parent / "bundled"

ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
*SEMANTIC_ANALYSES,
CFGAnalysis,
Expand Down Expand Up @@ -217,10 +221,15 @@ def _routes_of(manager: AnalysisManager) -> dict[SymbolId, EntryPoint]:


def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> PluginRegistry:
"""Every plugin below the roots, each manifest loaded once even when roots overlap."""

registry = PluginRegistry()
seen: set[Path] = set()
for root in plugin_roots:
for loaded in discover_plugins(root, manager):
registry.add(loaded)
if loaded.directory.resolve() not in seen:
seen.add(loaded.directory.resolve())
registry.add(loaded)
return registry


Expand Down
2 changes: 1 addition & 1 deletion tests/test_advisory_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def require_feeds() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def osv(identifier: str, package: str, events: list[dict[str, str]], **extra: object) -> dict[str, object]:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"dependency": 11,
"plugins": 12,
"reporters": 13,
# The plugins shipped with the package sit on top of the plugin API.
"bundled": 14,
}

# Top-level modules that wire the pipeline together and may import any layer.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_audit_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from coretrace_python.source import SourceManager

REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


@pytest.fixture(autouse=True)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def require_cache() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"

HELPERS = "import os\n\ndef execute(command):\n os.system(command)\n"
MAIN = "from app.helpers import execute\n\ndef run():\n execute(input())\n"
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from coretrace_python.cli import main

REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def write(tmp_path: Path, text: str, name: str = "target.py") -> Path:
Expand Down Expand Up @@ -60,7 +60,7 @@ def test_check_is_clean_when_nothing_is_found(tmp_path, capsys) -> None:
def test_check_without_plugins_finds_nothing(tmp_path, capsys) -> None:
source = write(tmp_path, "def run(code):\n eval(code)\n")

assert main(["--check", str(source)]) == 0
assert main(["--check", str(source), "--no-bundled-plugins"]) == 0
assert capsys.readouterr().out == "no findings\ncoverage: 1/1 files, 1/1 functions\n"


Expand Down
2 changes: 1 addition & 1 deletion tests/test_closures.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def require_closures() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def hir(text: str) -> nodes.Module:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config_secrets_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def require_pass() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"
GITHUB_TOKEN = "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"


Expand Down
2 changes: 1 addition & 1 deletion tests/test_correlation.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def require_correlation() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"

VULNERABLE = "pyyaml==5.3.1\n"

Expand Down
2 changes: 1 addition & 1 deletion tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def require_dependencies() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def project(root: Path, files: dict[str, str]) -> Path:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def require_detectors() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def check(source_text: str, *extra_roots: Path, name: str = "app.py") -> tuple[Finding, ...]:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def test_dashed_packages_are_analysed_instead_of_crashing(tmp_path: Path) -> Non
},
)

analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "plugins"])
analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled"])

assert set(analysis.keys) == {"network-topologer", "network-topologer.traceroute", "network-topologer.__main__"}
assert [(f.rule_id, Path(str(f.span.source_id)).name, f.span.start_line) for f in analysis.findings] == [
Expand All @@ -77,7 +77,7 @@ def test_undecodable_files_become_notes_and_the_rest_is_analysed(tmp_path: Path)
root = project(tmp_path, {"app.py": "import os\n\ndef run():\n os.system(input())\n"})
(root / "legacy.py").write_bytes(b"print('x')\n\xff")

analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "plugins"])
analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled"])

notes = [(f.rule_id, Path(str(f.span.source_id)).name) for f in analysis.findings]
assert ("syntax-error", "legacy.py") in notes
Expand All @@ -90,7 +90,7 @@ def test_utf16_dependency_files_are_read(tmp_path: Path) -> None:
root = project(tmp_path, {"app.py": "x = 1\n"})
(root / "requirements.txt").write_bytes("pyyaml==5.3.1\r\nflask==2.0.0\r\n".encode("utf-16"))

analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "plugins"])
analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled"])

assert analysis.dependencies.names == ("flask", "pyyaml")
assert sorted(f.rule_id for f in analysis.findings) == ["vulnerable-dependency", "vulnerable-dependency"]
Expand All @@ -114,5 +114,5 @@ def test_duplicate_qualified_names_get_distinct_call_graph_names() -> None:
setter = module.body[1].body[1] # type: ignore[union-attr]
assert graph.name_of(setter) == "Box.cmd__2" # type: ignore[arg-type]
assert manager.get(TaintAnalysis, setter) is not None # type: ignore[arg-type]
findings = engine.check(source, [Path(__file__).resolve().parent.parent / "plugins"])
findings = engine.check(source, [Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled"])
assert [(f.rule_id, f.span.start_line) for f in findings] == [("command-injection", 11)]
2 changes: 1 addition & 1 deletion tests/test_django_http_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def require_models() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"

OS_SYSTEM = Sink(SymbolId("python.os.system"), TaintKind.COMMAND)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_django_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def require_routes() -> None:


REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


def project(root: Path, files: dict[str, str]) -> Path:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from coretrace_python.source import SourceManager

REPO = Path(__file__).resolve().parent.parent
PLUGINS = REPO / "plugins"
PLUGINS = REPO / "src" / "coretrace_python" / "bundled"


@pytest.fixture(autouse=True)
Expand Down
Loading
Loading