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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ coretrace-python-analyzer --check app.py --plugins plugins/ --format sarif > rep
```

`--plugins` is a directory searched recursively for `plugin.toml` manifests and may be
repeated. `--format` is `text` (default), `json` or `sarif`. Exit status is 0 when nothing
repeated. The repository ships a first set under `plugins/`: standard-library security
models (`models/python_stdlib`), taint detectors for SQL injection, command injection,
path traversal, SSRF and XSS (`security/`), and syntactic detectors for `eval`/`exec`
and weak hashes (`syntax/`). Model plugins contribute sources, sinks and sanitizers;
detectors consume the shared taint result, so adding a framework model makes every
detector aware of it. `--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
Expand Down
9 changes: 9 additions & 0 deletions plugins/models/python_stdlib/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "python-stdlib-models"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["model.python-stdlib"]

[entrypoint]
module = "python_stdlib"
class = "PythonStdlibModels"
41 changes: 41 additions & 0 deletions plugins/models/python_stdlib/python_stdlib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Security models for the Python standard library (architecture §16)."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import Model, Sanitizer, Sink, Source, TaintKind


def _sym(path: str) -> SymbolId:
return SymbolId(f"python.{path}")


class PythonStdlibModels(ModelPlugin):
name: ClassVar[str] = "python-stdlib-models"
models: ClassVar[tuple[Model, ...]] = (
Source(_sym("builtins.input"), "stdin"),
Source(_sym("sys.stdin"), "stdin"),
Source(_sym("sys.argv"), "argv"),
Source(_sym("os.environ"), "environment"),
Sink(_sym("os.system"), TaintKind.COMMAND),
Sink(_sym("os.popen"), TaintKind.COMMAND),
Sink(_sym("subprocess.run"), TaintKind.COMMAND),
Sink(_sym("subprocess.call"), TaintKind.COMMAND),
Sink(_sym("subprocess.check_call"), TaintKind.COMMAND),
Sink(_sym("subprocess.check_output"), TaintKind.COMMAND),
Sink(_sym("subprocess.Popen"), TaintKind.COMMAND),
Sink(_sym("builtins.eval"), TaintKind.CODE),
Sink(_sym("builtins.exec"), TaintKind.CODE),
Sink(_sym("builtins.open"), TaintKind.PATH),
Sink(_sym("os.remove"), TaintKind.PATH),
Sink(_sym("os.unlink"), TaintKind.PATH),
Sink(_sym("os.rmdir"), TaintKind.PATH),
Sink(_sym("shutil.rmtree"), TaintKind.PATH),
Sink(_sym("urllib.request.urlopen"), TaintKind.SSRF),
Sanitizer(_sym("shlex.quote"), TaintKind.COMMAND),
Sanitizer(_sym("html.escape"), TaintKind.HTML),
Sanitizer(_sym("os.path.basename"), TaintKind.PATH),
)
17 changes: 17 additions & 0 deletions plugins/security/command_injection/command_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Command injection: attacker-controlled input reaching a COMMAND sink."""

from __future__ import annotations

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"
9 changes: 9 additions & 0 deletions plugins/security/command_injection/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "command-injection"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows"]
provides = ["vulnerability.command-injection"]

[entrypoint]
module = "command_injection"
class = "CommandInjectionPlugin"
17 changes: 17 additions & 0 deletions plugins/security/path_traversal/path_traversal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Path traversal: attacker-controlled input reaching a PATH sink."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import TaintDetector
from coretrace_python.taint import TaintKind


class PathTraversalPlugin(TaintDetector):
name: ClassVar[str] = "path-traversal"
rule_id: ClassVar[str] = "path-traversal"
kind: ClassVar[TaintKind] = TaintKind.PATH
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "Path traversal"
9 changes: 9 additions & 0 deletions plugins/security/path_traversal/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "path-traversal"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows"]
provides = ["vulnerability.path-traversal"]

[entrypoint]
module = "path_traversal"
class = "PathTraversalPlugin"
9 changes: 9 additions & 0 deletions plugins/security/sql_injection/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "sql-injection"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows"]
provides = ["vulnerability.sql-injection"]

[entrypoint]
module = "sql_injection"
class = "SqlInjectionPlugin"
17 changes: 17 additions & 0 deletions plugins/security/sql_injection/sql_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""SQL injection: attacker-controlled input reaching a SQL sink."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import TaintDetector
from coretrace_python.taint import TaintKind


class SqlInjectionPlugin(TaintDetector):
name: ClassVar[str] = "sql-injection"
rule_id: ClassVar[str] = "sql-injection"
kind: ClassVar[TaintKind] = TaintKind.SQL
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "SQL injection"
9 changes: 9 additions & 0 deletions plugins/security/ssrf/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "ssrf"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows"]
provides = ["vulnerability.ssrf"]

[entrypoint]
module = "ssrf"
class = "SsrfPlugin"
17 changes: 17 additions & 0 deletions plugins/security/ssrf/ssrf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Server-side request forgery: attacker-controlled input reaching a SSRF sink."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import TaintDetector
from coretrace_python.taint import TaintKind


class SsrfPlugin(TaintDetector):
name: ClassVar[str] = "ssrf"
rule_id: ClassVar[str] = "ssrf"
kind: ClassVar[TaintKind] = TaintKind.SSRF
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "Server-side request forgery"
9 changes: 9 additions & 0 deletions plugins/security/xss/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "xss"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows"]
provides = ["vulnerability.xss"]

[entrypoint]
module = "xss"
class = "XssPlugin"
17 changes: 17 additions & 0 deletions plugins/security/xss/xss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Cross-site scripting: attacker-controlled input reaching a HTML sink."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import TaintDetector
from coretrace_python.taint import TaintKind


class XssPlugin(TaintDetector):
name: ClassVar[str] = "xss"
rule_id: ClassVar[str] = "xss"
kind: ClassVar[TaintKind] = TaintKind.HTML
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "Cross-site scripting"
63 changes: 10 additions & 53 deletions plugins/syntax/dangerous_eval/dangerous_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,18 @@

from __future__ import annotations

from collections.abc import Iterator
from typing import ClassVar

from coretrace_python.analysis import AnyAnalysis
from coretrace_python.findings import Confidence, Finding, Severity
from coretrace_python.hir import nodes
from coretrace_python.hir.visitors import Node, children
from coretrace_python.plugins import Plugin, PluginContext
from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeId, ScopeTable
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId
from coretrace_python.findings import Severity
from coretrace_python.plugins import SymbolCallDetector
from coretrace_python.semantic.symbols import SymbolId

DANGEROUS = frozenset({SymbolId("python.builtins.eval"), SymbolId("python.builtins.exec")})


class DangerousEvalPlugin(Plugin):
class DangerousEvalPlugin(SymbolCallDetector):
name: ClassVar[str] = "dangerous-eval"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({ScopeAnalysis, SymbolAnalysis})

def analyze(self, ctx: PluginContext) -> list[Finding]:
scopes = ctx.get(ScopeAnalysis)
symbols = ctx.get(SymbolAnalysis)
findings: list[Finding] = []
for function in ctx.functions():
for call, scope_id, enclosing in _calls(function, scopes, scopes.scope_for(function).id):
if not isinstance(call.callee, nodes.Name):
continue
symbol = symbols.resolve(scope_id, call.callee.identifier)
if symbol in DANGEROUS:
findings.append(
Finding(
rule_id="dangerous-eval",
message=f"call to {symbol} executes dynamically built code",
severity=Severity.HIGH,
confidence=Confidence.HIGH,
span=call.span,
function=enclosing,
)
)
return findings


def _calls(
function: nodes.Function, scopes: ScopeTable, scope_id: ScopeId
) -> Iterator[tuple[nodes.Call, ScopeId, str]]:
"""Yield every call in ``function`` with the scope it is evaluated in."""

def walk(node: Node, scope_id: ScopeId, enclosing: str) -> Iterator[tuple[nodes.Call, ScopeId, str]]:
if isinstance(node, nodes.Call):
yield node, scope_id, enclosing
if isinstance(node, nodes.Function):
scope_id, enclosing = scopes.scope_for(node).id, node.name
elif isinstance(node, nodes.Class | nodes.Comprehension):
scope_id = scopes.scope_for(node).id
for child in children(node):
yield from walk(child, scope_id, enclosing)

for statement in function.body:
yield from walk(statement, scope_id, function.name)
rule_id: ClassVar[str] = "dangerous-eval"
symbols: ClassVar[frozenset[SymbolId]] = frozenset(
{SymbolId("python.builtins.eval"), SymbolId("python.builtins.exec")}
)
severity: ClassVar[Severity] = Severity.HIGH
message_template: ClassVar[str] = "call to {symbol} executes dynamically built code"
2 changes: 1 addition & 1 deletion plugins/syntax/dangerous_eval/plugin.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "dangerous-eval"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["semantic.scopes", "semantic.symbols"]
requires = ["ir.ssa"]
provides = ["vulnerability.dangerous-eval"]

[entrypoint]
Expand Down
9 changes: 9 additions & 0 deletions plugins/syntax/weak_crypto/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "weak-crypto"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["ir.ssa"]
provides = ["vulnerability.weak-crypto"]

[entrypoint]
module = "weak_crypto"
class = "WeakCryptoPlugin"
19 changes: 19 additions & 0 deletions plugins/syntax/weak_crypto/weak_crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Report uses of broken hash algorithms."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import SymbolCallDetector
from coretrace_python.semantic.symbols import SymbolId


class WeakCryptoPlugin(SymbolCallDetector):
name: ClassVar[str] = "weak-crypto"
rule_id: ClassVar[str] = "weak-crypto"
symbols: ClassVar[frozenset[SymbolId]] = frozenset(
{SymbolId("python.hashlib.md5"), SymbolId("python.hashlib.sha1")}
)
severity: ClassVar[Severity] = Severity.MEDIUM
message_template: ClassVar[str] = "call to {symbol} uses a broken hash algorithm"
25 changes: 20 additions & 5 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,40 @@
)


def build_manager(module: nodes.Module, models: SecurityModelRegistry | None = None) -> AnalysisManager:
def build_manager(
module: nodes.Module, models: SecurityModelRegistry | None = None
) -> AnalysisManager:
"""A manager with every engine analysis registered and the security models provided."""

manager = AnalysisManager(module)
manager.register(*ALL_ANALYSES)
manager = _register_all(module)
manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze())
return manager


def check(source: SourceFile, plugin_roots: Sequence[Path]) -> tuple[Finding, ...]:
"""Run every plugin found under ``plugin_roots`` against ``source``."""
"""Run every plugin found under ``plugin_roots`` against ``source``.

manager = build_manager(build_hir(source))
Plugins are loaded first so the models they contribute are all registered before the
table is provided; framework models and detectors compose without knowing each other.
"""

manager = _register_all(build_hir(source))
registry = PluginRegistry()
for root in plugin_roots:
for loaded in discover_plugins(root, manager):
registry.add(loaded)
models = SecurityModelRegistry()
for loaded in registry:
models.register(*loaded.plugin.models)
manager.provide(SecurityModelAnalysis, models.freeze())
return run_plugins(manager, [loaded.plugin for loaded in registry])


def _register_all(module: nodes.Module) -> AnalysisManager:
manager = AnalysisManager(module)
manager.register(*ALL_ANALYSES)
return manager


def report(findings: Sequence[Finding]) -> Report:
return Report(tuple(findings), TOOL_NAME, __version__)
12 changes: 11 additions & 1 deletion src/coretrace_python/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
"""Plugin API, manifests, loader and registry (architecture §13, §14, §32–§34)."""

from coretrace_python.plugins.api import PLUGIN_API_VERSION, Plugin, PluginContext, run_plugins
from coretrace_python.plugins.api import (
PLUGIN_API_VERSION,
ModelPlugin,
Plugin,
PluginContext,
run_plugins,
)
from coretrace_python.plugins.detectors import SymbolCallDetector, TaintDetector
from coretrace_python.plugins.loader import (
IncompatiblePluginError,
LoadedPlugin,
Expand All @@ -22,10 +29,13 @@
"IncompatiblePluginError",
"LoadedPlugin",
"ManifestError",
"ModelPlugin",
"Plugin",
"PluginContext",
"PluginManifest",
"PluginRegistry",
"SymbolCallDetector",
"TaintDetector",
"VersionRange",
"discover_plugins",
"load_manifest",
Expand Down
Loading
Loading