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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ attacker-controlled data reaches such a call, the engine correlates the four fac
one critical `exploitable-vulnerability` finding, judged like any other flow. The shipped
advisory database is a small offline sample; a live OSV feed is future work.

`--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, 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. Other plugins add
providers by subclassing `SecretDetector` with their own patterns. Configuration files
are not scanned.

`--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
Expand Down
36 changes: 36 additions & 0 deletions plugins/secrets/hardcoded_secrets/hardcoded_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Hardcoded secrets: provider formats, credential-like names, high-entropy tokens."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.plugins.secrets import SecretDetector, SecretPattern


class HardcodedSecrets(SecretDetector):
name: ClassVar[str] = "hardcoded-secrets"
patterns: ClassVar[tuple[SecretPattern, ...]] = (
SecretPattern("aws", r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
SecretPattern("github", r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"),
SecretPattern("github", r"\bgithub_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}\b"),
SecretPattern("slack", r"\bxox[abpr]-[0-9]{10,}-[0-9A-Za-z-]{10,}"),
SecretPattern("stripe", r"\b[sr]k_(live|test)_[0-9A-Za-z]{24,}\b"),
SecretPattern("google", r"\bAIza[0-9A-Za-z_-]{35}\b"),
SecretPattern("private-key", r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
SecretPattern("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
SecretPattern("sendgrid", r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"),
SecretPattern("twilio", r"\bSK[0-9a-fA-F]{32}\b"),
)
credential_names: ClassVar[tuple[str, ...]] = (
"password",
"passwd",
"pwd",
"secret",
"token",
"api_key",
"apikey",
"api-key",
"private_key",
"access_key",
"credential",
)
9 changes: 9 additions & 0 deletions plugins/secrets/hardcoded_secrets/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "hardcoded-secrets"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["secret.provider-patterns", "secret.credentials", "secret.entropy"]

[entrypoint]
module = "hardcoded_secrets"
class = "HardcodedSecrets"
23 changes: 20 additions & 3 deletions src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import sys
from pathlib import Path

from coretrace_python import engine
from coretrace_python import __version__, engine
from coretrace_python.analysis import AnalysisError
from coretrace_python.cache import ProjectCache
from coretrace_python.cfg import CFGError
from coretrace_python.dependency import render_sbom
from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
from coretrace_python.ir.lowering import LoweringError, lower_module
from coretrace_python.ir.printer import format_module
Expand Down Expand Up @@ -86,6 +87,13 @@ def build_parser() -> argparse.ArgumentParser:
metavar="N",
help="with --check on a directory, analyse independent modules in N processes",
)
parser.add_argument(
"--sbom",
type=Path,
default=None,
metavar="PATH",
help="with --check on a directory, write a CycloneDX bill of materials to PATH",
)
return parser


Expand All @@ -106,6 +114,9 @@ def main(argv: list[str] | None = None) -> int:
if args.jobs is not None and args.jobs < 1:
print("error: --jobs must be at least 1", file=sys.stderr)
return EXIT_ERROR
if args.sbom is not None and not (args.check and args.path.is_dir()):
print("error: --sbom only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR

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)
Expand All @@ -118,9 +129,15 @@ def main(argv: list[str] | None = None) -> int:
if args.check:
if args.path.is_dir():
cache = None if args.cache is None else ProjectCache(args.cache)
findings = engine.analyze_project(
analysis = engine.analyze_project(
args.path, args.plugins, cache=cache, jobs=args.jobs or 1
).findings
)
findings = analysis.findings
if args.sbom is not None:
args.sbom.write_text(
render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__),
encoding="utf-8",
)
else:
findings = engine.check(SourceManager().load_file(args.path), args.plugins)
print(render(args.format or "text", engine.report(findings)), end="")
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/dependency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
normalize,
parse_dependencies,
)
from coretrace_python.dependency.sbom import render_sbom

__all__ = [
"DEPENDENCY_FILES",
Expand All @@ -20,4 +21,5 @@
"Version",
"normalize",
"parse_dependencies",
"render_sbom",
]
65 changes: 65 additions & 0 deletions src/coretrace_python/dependency/sbom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""CycloneDX software bill of materials from the dependency graph (architecture §26)."""

from __future__ import annotations

import json
from collections.abc import Iterable

from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement

SPEC_VERSION = "1.5"


def purl(requirement: Requirement) -> str:
base = f"pkg:pypi/{requirement.name}"
if requirement.pinned is None:
return base
return f"{base}@{'.'.join(str(part) for part in requirement.pinned.parts)}"


def _component(requirement: Requirement) -> dict[str, object]:
reference = purl(requirement)
component: dict[str, object] = {"type": "library", "bom-ref": reference, "name": requirement.name}
if requirement.pinned is not None:
component["version"] = ".".join(str(part) for part in requirement.pinned.parts)
component["purl"] = reference
properties: list[dict[str, str]] = []
if requirement.specifier:
properties.append({"name": "coretrace:specifier", "value": requirement.specifier})
if requirement.optional:
properties.append({"name": "coretrace:optional", "value": "true"})
if properties:
component["properties"] = properties
return component


def render_sbom(
dependencies: DependencyGraph, advisories: Iterable[Advisory], tool_name: str, tool_version: str
) -> str:
"""A CycloneDX JSON document: one component per requirement, and the advisories
affecting them as vulnerabilities. Deterministic for a given graph."""

requirements = dependencies.requirements
vulnerabilities: list[dict[str, object]] = []
for advisory in sorted(advisories, key=lambda a: a.id):
affected = [purl(r) for r in requirements if advisory.affects(r)]
if affected:
vulnerabilities.append(
{
"id": advisory.id,
"description": advisory.summary,
"ratings": [{"severity": advisory.severity.value}],
"affects": [{"ref": reference} for reference in affected],
}
)
document = {
"bomFormat": "CycloneDX",
"specVersion": SPEC_VERSION,
"version": 1,
"metadata": {
"tools": {"components": [{"type": "application", "name": tool_name, "version": tool_version}]}
},
"components": [_component(requirement) for requirement in requirements],
"vulnerabilities": vulnerabilities,
}
return json.dumps(document, indent=2) + "\n"
5 changes: 4 additions & 1 deletion src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class ProjectAnalysis:
dependencies: DependencyGraph = field(default_factory=DependencyGraph)
keys: Mapping[str, str] = field(default_factory=_no_keys)
reused: tuple[str, ...] = ()
advisories: tuple[Advisory, ...] = ()


def build_manager(
Expand Down Expand Up @@ -331,7 +332,9 @@ def analyze_project(
for plugin in all_plugins:
if isinstance(plugin, ProjectPlugin):
findings.extend(plugin.analyze_project(context))
return ProjectAnalysis(graph, index, tuple(findings), dependencies, MappingProxyType(keys), reused)
return ProjectAnalysis(
graph, index, tuple(findings), dependencies, MappingProxyType(keys), reused, advisories
)


def _seed(results: Mapping[str, CachedModule], graph: ModuleGraph, component: frozenset[str]) -> SummaryIndex:
Expand Down
5 changes: 5 additions & 0 deletions src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
if len(node.targets) != 1:
self.fail(node, "chained assignment is not supported yet")
return nodes.Assign(self.target(node.targets[0]), self.expression(node.value), span)
if isinstance(node, ast.AnnAssign):
# The annotation never affects behaviour; a bare declaration does nothing.
if node.value is None:
return nodes.Pass(span)
return nodes.Assign(self.target(node.target), self.expression(node.value), span)
if isinstance(node, ast.AugAssign):
operator = _BINARY_OPERATORS.get(type(node.op))
if operator is None:
Expand Down
10 changes: 10 additions & 0 deletions src/coretrace_python/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
load_manifest,
)
from coretrace_python.plugins.registry import PluginRegistry
from coretrace_python.plugins.secrets import (
SecretDetector,
SecretPattern,
literals,
shannon_entropy,
)

__all__ = [
"PLUGIN_API_VERSION",
Expand All @@ -38,11 +44,15 @@
"PluginRegistry",
"ProjectContext",
"ProjectPlugin",
"SecretDetector",
"SecretPattern",
"SymbolCallDetector",
"TaintDetector",
"VersionRange",
"discover_plugins",
"literals",
"load_manifest",
"load_plugin",
"run_plugins",
"shannon_entropy",
]
Loading
Loading