phantom-scan 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. phantom_scan-0.1.0/PKG-INFO +155 -0
  2. phantom_scan-0.1.0/README.md +143 -0
  3. phantom_scan-0.1.0/phantom/__init__.py +12 -0
  4. phantom_scan-0.1.0/phantom/cache.py +63 -0
  5. phantom_scan-0.1.0/phantom/cli.py +133 -0
  6. phantom_scan-0.1.0/phantom/core.py +81 -0
  7. phantom_scan-0.1.0/phantom/differ.py +129 -0
  8. phantom_scan-0.1.0/phantom/ecosystems/__init__.py +3 -0
  9. phantom_scan-0.1.0/phantom/ecosystems/base.py +54 -0
  10. phantom_scan-0.1.0/phantom/ecosystems/npm.py +9 -0
  11. phantom_scan-0.1.0/phantom/ecosystems/pypi.py +203 -0
  12. phantom_scan-0.1.0/phantom/errors.py +19 -0
  13. phantom_scan-0.1.0/phantom/models.py +148 -0
  14. phantom_scan-0.1.0/phantom/normalizers/__init__.py +4 -0
  15. phantom_scan-0.1.0/phantom/normalizers/base.py +18 -0
  16. phantom_scan-0.1.0/phantom/normalizers/python_ast.py +24 -0
  17. phantom_scan-0.1.0/phantom/registry.py +35 -0
  18. phantom_scan-0.1.0/phantom/report/__init__.py +5 -0
  19. phantom_scan-0.1.0/phantom/report/json.py +11 -0
  20. phantom_scan-0.1.0/phantom/report/sarif.py +73 -0
  21. phantom_scan-0.1.0/phantom/report/table.py +56 -0
  22. phantom_scan-0.1.0/phantom/risk.py +106 -0
  23. phantom_scan-0.1.0/phantom_scan.egg-info/PKG-INFO +155 -0
  24. phantom_scan-0.1.0/phantom_scan.egg-info/SOURCES.txt +35 -0
  25. phantom_scan-0.1.0/phantom_scan.egg-info/dependency_links.txt +1 -0
  26. phantom_scan-0.1.0/phantom_scan.egg-info/entry_points.txt +2 -0
  27. phantom_scan-0.1.0/phantom_scan.egg-info/requires.txt +3 -0
  28. phantom_scan-0.1.0/phantom_scan.egg-info/top_level.txt +1 -0
  29. phantom_scan-0.1.0/pyproject.toml +32 -0
  30. phantom_scan-0.1.0/setup.cfg +4 -0
  31. phantom_scan-0.1.0/tests/test_cli.py +73 -0
  32. phantom_scan-0.1.0/tests/test_network.py +43 -0
  33. phantom_scan-0.1.0/tests/test_normalizer.py +33 -0
  34. phantom_scan-0.1.0/tests/test_pypi.py +75 -0
  35. phantom_scan-0.1.0/tests/test_reports.py +67 -0
  36. phantom_scan-0.1.0/tests/test_risk.py +41 -0
  37. phantom_scan-0.1.0/tests/test_scan.py +97 -0
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: phantom-scan
3
+ Version: 0.1.0
4
+ Summary: Detect divergence between a package's declared source and its published artifact
5
+ License: MIT
6
+ Project-URL: Source, https://github.com/pipebreach/phantom
7
+ Project-URL: Repository, https://github.com/pipebreach/phantom
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+
13
+ # phantom
14
+
15
+ **Detects divergence between a package's declared source and the artifact it actually publishes.**
16
+
17
+ Part of the [pipebreach] research project. `phantom` compares *what a package declares* (its source repository at the tag matching the published version) against *what it ships* (the wheel on PyPI). Any code present in the artifact that does not exist in the source is a **phantom** — flagged regardless of whether it "looks malicious".
18
+
19
+ ## Threat model
20
+
21
+ An attacker compromises a package's build/publish pipeline and injects code into the distributed artifact **without touching the source repository**. Anyone auditing the code on GitHub sees nothing; anyone installing the package gets the backdoor.
22
+
23
+ Real-world incidents this class of tool catches:
24
+
25
+ - **LiteLLM (March 2026):** a three-stage payload (credential theft, Kubernetes lateral movement, RCE backdoor) injected into the PyPI wheel post-build. The repo was clean.
26
+ - **XZ Utils (CVE-2024-3094):** backdoor present in the distribution tarball but not in git.
27
+
28
+ Malware scanners (GuardDog, Semgrep rules, etc.) look for *known-bad patterns*. `phantom` is **pattern-agnostic**: it doesn't care whether injected code looks bad — only that it isn't in the source. That catches clean, obfuscated, or never-before-seen injection. Reproducible-builds infrastructure (rebuilderd) solves this for OS distros; nothing equivalent exists for language ecosystems. That's the gap.
29
+
30
+ ## Quickstart
31
+
32
+ Requires Python 3.11+. Zero runtime dependencies (stdlib only, by design).
33
+
34
+ ```bash
35
+ pip install -e .
36
+
37
+ # Scan a single package version
38
+ phantom scan requests==2.31.0
39
+
40
+ # Machine-readable output
41
+ phantom scan requests==2.31.0 --json
42
+ phantom scan requests==2.31.0 --sarif # for GitHub code scanning
43
+ ```
44
+
45
+ What a scan does:
46
+
47
+ 1. Downloads the pure-Python wheel from the PyPI JSON API.
48
+ 2. Resolves the source repo from the package metadata (`project_urls`) and finds the tag matching the version (`v{ver}`, `{ver}`, `release-{ver}`).
49
+ 3. Computes an **AST-normalized hash** of every `.py` file on both sides (comments/whitespace/formatting don't count) and flags every wheel file whose content exists nowhere in the source.
50
+ 4. Grades each phantom by its execution vectors (subprocess, network, `exec`/`eval`, `os.environ` access). Reading data + network egress = `critical` (exfiltration shape).
51
+ 5. Any `.pth` file inside a wheel is always flagged (it executes at interpreter startup).
52
+
53
+ Downloads are cached on disk (`~/.cache/phantom` by default, `--cache-dir` to override), so re-scanning the same `pkg==version` is deterministic and offline.
54
+
55
+ ## Exit codes
56
+
57
+ | Code | Meaning |
58
+ |------|---------|
59
+ | 0 | No findings (or only medium/low severity) |
60
+ | 1 | At least one **high**/**critical** finding |
61
+ | 2 | Execution error (bad arguments, network failure, package not found) |
62
+ | 3 | Out of scope: the release has no pure-Python wheel (binary wheels or sdist only) |
63
+
64
+ ## GitHub Action
65
+
66
+ Package maintainers can verify their own releases right after publishing —
67
+ this catches a compromised build/publish pipeline (the LiteLLM case) even
68
+ though the repo looks clean:
69
+
70
+ ```yaml
71
+ name: verify-release
72
+ on:
73
+ workflow_run:
74
+ workflows: [publish] # run after your publish workflow completes
75
+ types: [completed]
76
+
77
+ jobs:
78
+ phantom:
79
+ runs-on: ubuntu-latest
80
+ permissions:
81
+ security-events: write # for SARIF upload
82
+ steps:
83
+ - uses: pipebreach/phantom@main # pin to a release commit SHA
84
+ id: scan
85
+ with:
86
+ spec: mypkg==1.2.3 # e.g. derived from the release tag
87
+ - uses: github/codeql-action/upload-sarif@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
88
+ if: always() && hashFiles('phantom-results.sarif') != ''
89
+ with:
90
+ sarif_file: phantom-results.sarif
91
+ ```
92
+
93
+ The job fails when phantom finds a high/critical divergence. Notes:
94
+
95
+ - Scan *after* the artifact is live on PyPI (publishing and scanning in the
96
+ same workflow can race index propagation).
97
+ - If your tags carry a `v` prefix, strip it for the spec: phantom probes tag
98
+ conventions on the source side, but the spec version must match PyPI.
99
+ - Releases without a pure-Python wheel emit a warning instead of failing;
100
+ set `fail-on-out-of-scope: "true"` to make them fail.
101
+
102
+ Inputs: `spec` (required), `ecosystem` (default `pypi`), `sarif-file`
103
+ (default `phantom-results.sarif`), `fail-on-out-of-scope` (default `false`).
104
+ Outputs: `exit-code`, `sarif-file`.
105
+
106
+ Scanning a whole lockfile from a consumer project (`phantom audit`) will use
107
+ the same action once M2 lands.
108
+
109
+ ## Finding types
110
+
111
+ | Type | Meaning | Severity |
112
+ |------|---------|----------|
113
+ | `phantom_file` | A `.py` in the wheel whose content exists nowhere in the source | `medium`–`critical` per execution vectors |
114
+ | `suspicious_pth` | A `.pth` file shipped inside the wheel | `high`; `critical` if it has import lines |
115
+ | `no_source_declared` | No source repo in the package metadata — unverifiable by construction | `high` |
116
+ | `source_ref_not_found` | Repo declared, but no tag matches the version | `medium` |
117
+
118
+ Every finding carries a `confidence` and a `reason`: phantom prefers saying "this is phantom but might be build-generated code" (e.g. `_version.py` from setuptools-scm gets `low` confidence) over false certainty.
119
+
120
+ The `--json` output is a versioned public contract (`schema_version`); breaking changes bump the version.
121
+
122
+ ## Library use
123
+
124
+ The CLI is a thin layer over the core (usable in CI, batch jobs, services):
125
+
126
+ ```python
127
+ from phantom.cache import DiskCache, default_cache_dir
128
+ from phantom.core import scan
129
+ from phantom.registry import build_default_registry
130
+
131
+ registry = build_default_registry(DiskCache(default_cache_dir()))
132
+ result = scan("six", "1.16.0", registry.get("pypi"))
133
+ print(result.to_dict())
134
+ ```
135
+
136
+ ## Known limits (M1)
137
+
138
+ Explicitly **not supported yet** — the plugin architecture (`Ecosystem`/`Fetcher`/`SourceResolver`/`Normalizer` interfaces) is designed so these land without touching the core:
139
+
140
+ - **Compiled/binary wheels and sdist-only releases** — reported as out of scope (exit 3). Needs build-step normalizers (M3).
141
+ - **npm / JavaScript** — no minification/transpilation normalizers yet (M2/M3).
142
+ - **Phantom *spans*** — code injected *into* a file that does exist in the source. M1 only detects whole phantom files; intra-file AST diffing is M2. A modified file will still show up as a phantom (its hash won't match), but without span-level localization.
143
+ - **`phantom audit <lockfile>`** — registered but stubbed (M2).
144
+ - **Non-GitHub forges** (GitLab, Codeberg…) — M3.
145
+ - **Tag-based resolution only** — packages that publish from a commit without tagging that version yield `source_ref_not_found`.
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ pip install -e ".[dev]"
151
+ pytest # unit tests, fully offline
152
+ PHANTOM_NETWORK_TESTS=1 pytest -m network # integration tests against real PyPI/GitHub
153
+ ```
154
+
155
+ [pipebreach]: https://github.com/pipebreach
@@ -0,0 +1,143 @@
1
+ # phantom
2
+
3
+ **Detects divergence between a package's declared source and the artifact it actually publishes.**
4
+
5
+ Part of the [pipebreach] research project. `phantom` compares *what a package declares* (its source repository at the tag matching the published version) against *what it ships* (the wheel on PyPI). Any code present in the artifact that does not exist in the source is a **phantom** — flagged regardless of whether it "looks malicious".
6
+
7
+ ## Threat model
8
+
9
+ An attacker compromises a package's build/publish pipeline and injects code into the distributed artifact **without touching the source repository**. Anyone auditing the code on GitHub sees nothing; anyone installing the package gets the backdoor.
10
+
11
+ Real-world incidents this class of tool catches:
12
+
13
+ - **LiteLLM (March 2026):** a three-stage payload (credential theft, Kubernetes lateral movement, RCE backdoor) injected into the PyPI wheel post-build. The repo was clean.
14
+ - **XZ Utils (CVE-2024-3094):** backdoor present in the distribution tarball but not in git.
15
+
16
+ Malware scanners (GuardDog, Semgrep rules, etc.) look for *known-bad patterns*. `phantom` is **pattern-agnostic**: it doesn't care whether injected code looks bad — only that it isn't in the source. That catches clean, obfuscated, or never-before-seen injection. Reproducible-builds infrastructure (rebuilderd) solves this for OS distros; nothing equivalent exists for language ecosystems. That's the gap.
17
+
18
+ ## Quickstart
19
+
20
+ Requires Python 3.11+. Zero runtime dependencies (stdlib only, by design).
21
+
22
+ ```bash
23
+ pip install -e .
24
+
25
+ # Scan a single package version
26
+ phantom scan requests==2.31.0
27
+
28
+ # Machine-readable output
29
+ phantom scan requests==2.31.0 --json
30
+ phantom scan requests==2.31.0 --sarif # for GitHub code scanning
31
+ ```
32
+
33
+ What a scan does:
34
+
35
+ 1. Downloads the pure-Python wheel from the PyPI JSON API.
36
+ 2. Resolves the source repo from the package metadata (`project_urls`) and finds the tag matching the version (`v{ver}`, `{ver}`, `release-{ver}`).
37
+ 3. Computes an **AST-normalized hash** of every `.py` file on both sides (comments/whitespace/formatting don't count) and flags every wheel file whose content exists nowhere in the source.
38
+ 4. Grades each phantom by its execution vectors (subprocess, network, `exec`/`eval`, `os.environ` access). Reading data + network egress = `critical` (exfiltration shape).
39
+ 5. Any `.pth` file inside a wheel is always flagged (it executes at interpreter startup).
40
+
41
+ Downloads are cached on disk (`~/.cache/phantom` by default, `--cache-dir` to override), so re-scanning the same `pkg==version` is deterministic and offline.
42
+
43
+ ## Exit codes
44
+
45
+ | Code | Meaning |
46
+ |------|---------|
47
+ | 0 | No findings (or only medium/low severity) |
48
+ | 1 | At least one **high**/**critical** finding |
49
+ | 2 | Execution error (bad arguments, network failure, package not found) |
50
+ | 3 | Out of scope: the release has no pure-Python wheel (binary wheels or sdist only) |
51
+
52
+ ## GitHub Action
53
+
54
+ Package maintainers can verify their own releases right after publishing —
55
+ this catches a compromised build/publish pipeline (the LiteLLM case) even
56
+ though the repo looks clean:
57
+
58
+ ```yaml
59
+ name: verify-release
60
+ on:
61
+ workflow_run:
62
+ workflows: [publish] # run after your publish workflow completes
63
+ types: [completed]
64
+
65
+ jobs:
66
+ phantom:
67
+ runs-on: ubuntu-latest
68
+ permissions:
69
+ security-events: write # for SARIF upload
70
+ steps:
71
+ - uses: pipebreach/phantom@main # pin to a release commit SHA
72
+ id: scan
73
+ with:
74
+ spec: mypkg==1.2.3 # e.g. derived from the release tag
75
+ - uses: github/codeql-action/upload-sarif@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
76
+ if: always() && hashFiles('phantom-results.sarif') != ''
77
+ with:
78
+ sarif_file: phantom-results.sarif
79
+ ```
80
+
81
+ The job fails when phantom finds a high/critical divergence. Notes:
82
+
83
+ - Scan *after* the artifact is live on PyPI (publishing and scanning in the
84
+ same workflow can race index propagation).
85
+ - If your tags carry a `v` prefix, strip it for the spec: phantom probes tag
86
+ conventions on the source side, but the spec version must match PyPI.
87
+ - Releases without a pure-Python wheel emit a warning instead of failing;
88
+ set `fail-on-out-of-scope: "true"` to make them fail.
89
+
90
+ Inputs: `spec` (required), `ecosystem` (default `pypi`), `sarif-file`
91
+ (default `phantom-results.sarif`), `fail-on-out-of-scope` (default `false`).
92
+ Outputs: `exit-code`, `sarif-file`.
93
+
94
+ Scanning a whole lockfile from a consumer project (`phantom audit`) will use
95
+ the same action once M2 lands.
96
+
97
+ ## Finding types
98
+
99
+ | Type | Meaning | Severity |
100
+ |------|---------|----------|
101
+ | `phantom_file` | A `.py` in the wheel whose content exists nowhere in the source | `medium`–`critical` per execution vectors |
102
+ | `suspicious_pth` | A `.pth` file shipped inside the wheel | `high`; `critical` if it has import lines |
103
+ | `no_source_declared` | No source repo in the package metadata — unverifiable by construction | `high` |
104
+ | `source_ref_not_found` | Repo declared, but no tag matches the version | `medium` |
105
+
106
+ Every finding carries a `confidence` and a `reason`: phantom prefers saying "this is phantom but might be build-generated code" (e.g. `_version.py` from setuptools-scm gets `low` confidence) over false certainty.
107
+
108
+ The `--json` output is a versioned public contract (`schema_version`); breaking changes bump the version.
109
+
110
+ ## Library use
111
+
112
+ The CLI is a thin layer over the core (usable in CI, batch jobs, services):
113
+
114
+ ```python
115
+ from phantom.cache import DiskCache, default_cache_dir
116
+ from phantom.core import scan
117
+ from phantom.registry import build_default_registry
118
+
119
+ registry = build_default_registry(DiskCache(default_cache_dir()))
120
+ result = scan("six", "1.16.0", registry.get("pypi"))
121
+ print(result.to_dict())
122
+ ```
123
+
124
+ ## Known limits (M1)
125
+
126
+ Explicitly **not supported yet** — the plugin architecture (`Ecosystem`/`Fetcher`/`SourceResolver`/`Normalizer` interfaces) is designed so these land without touching the core:
127
+
128
+ - **Compiled/binary wheels and sdist-only releases** — reported as out of scope (exit 3). Needs build-step normalizers (M3).
129
+ - **npm / JavaScript** — no minification/transpilation normalizers yet (M2/M3).
130
+ - **Phantom *spans*** — code injected *into* a file that does exist in the source. M1 only detects whole phantom files; intra-file AST diffing is M2. A modified file will still show up as a phantom (its hash won't match), but without span-level localization.
131
+ - **`phantom audit <lockfile>`** — registered but stubbed (M2).
132
+ - **Non-GitHub forges** (GitLab, Codeberg…) — M3.
133
+ - **Tag-based resolution only** — packages that publish from a commit without tagging that version yield `source_ref_not_found`.
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ pip install -e ".[dev]"
139
+ pytest # unit tests, fully offline
140
+ PHANTOM_NETWORK_TESTS=1 pytest -m network # integration tests against real PyPI/GitHub
141
+ ```
142
+
143
+ [pipebreach]: https://github.com/pipebreach
@@ -0,0 +1,12 @@
1
+ """phantom — detect divergence between a package's source and its published artifact.
2
+
3
+ Core entry point for library use::
4
+
5
+ from phantom.core import scan
6
+ from phantom.registry import build_default_registry
7
+
8
+ registry = build_default_registry()
9
+ result = scan("some-pkg", "1.2.3", registry.get("pypi"))
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,63 @@
1
+ """Disk cache keyed by URL digest and the single HTTP helper. A cached
2
+ scan of the same ``pkg==version`` is deterministic and works offline."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import urllib.error
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from phantom.errors import FetchError, NotFoundError
12
+
13
+ USER_AGENT = "phantom/0.1 (+https://github.com/pipebreach/phantom)"
14
+
15
+
16
+ def default_cache_dir() -> Path:
17
+ return Path.home() / ".cache" / "phantom"
18
+
19
+
20
+ class DiskCache:
21
+ """Blob store keyed by the SHA-256 of an arbitrary string key."""
22
+
23
+ def __init__(self, root: Path) -> None:
24
+ self.root = root
25
+
26
+ def _path(self, key: str) -> Path:
27
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
28
+ return self.root / digest[:2] / digest
29
+
30
+ def get(self, key: str) -> bytes | None:
31
+ path = self._path(key)
32
+ if path.is_file():
33
+ return path.read_bytes()
34
+ return None
35
+
36
+ def put(self, key: str, data: bytes) -> None:
37
+ path = self._path(key)
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ tmp = path.with_suffix(".tmp")
40
+ tmp.write_bytes(data)
41
+ tmp.replace(path)
42
+
43
+
44
+ def http_get(url: str, cache: DiskCache | None = None, timeout: float = 60.0) -> bytes:
45
+ """GET a URL through the cache. Raises ``NotFoundError`` on 404,
46
+ ``FetchError`` on any other failure."""
47
+ if cache is not None:
48
+ cached = cache.get(url)
49
+ if cached is not None:
50
+ return cached
51
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
52
+ try:
53
+ with urllib.request.urlopen(request, timeout=timeout) as response:
54
+ data = response.read()
55
+ except urllib.error.HTTPError as exc:
56
+ if exc.code == 404:
57
+ raise NotFoundError(f"404 Not Found: {url}") from exc
58
+ raise FetchError(f"HTTP {exc.code} fetching {url}") from exc
59
+ except urllib.error.URLError as exc:
60
+ raise FetchError(f"network error fetching {url}: {exc.reason}") from exc
61
+ if cache is not None:
62
+ cache.put(url, data)
63
+ return data
@@ -0,0 +1,133 @@
1
+ """Thin CLI layer over ``phantom.core.scan``. Exit codes: 0 clean, 1 findings
2
+ of high/critical severity, 2 execution error, 3 out of scope."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from phantom import __version__, core
11
+ from phantom.cache import DiskCache, default_cache_dir
12
+ from phantom.errors import OutOfScopeError, PhantomError
13
+ from phantom.registry import Registry, build_default_registry
14
+ from phantom.report import json_report, sarif_report, table_report
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FINDINGS = 1
18
+ EXIT_ERROR = 2
19
+ EXIT_OUT_OF_SCOPE = 3
20
+
21
+ _EXIT_CODES_HELP = """\
22
+ exit codes:
23
+ 0 no findings (or only medium/low severity)
24
+ 1 at least one high/critical finding
25
+ 2 execution error
26
+ 3 package out of scope (no pure-Python wheel available)
27
+ """
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ prog="phantom",
33
+ description=(
34
+ "Detect divergence between a package's declared source and its "
35
+ "published artifact."
36
+ ),
37
+ epilog=_EXIT_CODES_HELP,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument("--version", action="version", version=f"phantom {__version__}")
41
+ subparsers = parser.add_subparsers(dest="command", required=True)
42
+
43
+ scan = subparsers.add_parser(
44
+ "scan",
45
+ help="scan a single package version (e.g. phantom scan requests==2.31.0)",
46
+ epilog=_EXIT_CODES_HELP,
47
+ formatter_class=argparse.RawDescriptionHelpFormatter,
48
+ )
49
+ scan.add_argument("spec", help="package spec in the form <pkg>==<version>")
50
+ scan.add_argument(
51
+ "--ecosystem",
52
+ default="pypi",
53
+ help="package ecosystem (default: pypi)",
54
+ )
55
+ output = scan.add_mutually_exclusive_group()
56
+ output.add_argument(
57
+ "--json", action="store_true", help="emit the versioned JSON schema"
58
+ )
59
+ output.add_argument(
60
+ "--sarif", action="store_true", help="emit SARIF 2.1.0 for code scanning"
61
+ )
62
+ scan.add_argument(
63
+ "--cache-dir",
64
+ type=Path,
65
+ default=None,
66
+ help=f"download cache directory (default: {default_cache_dir()})",
67
+ )
68
+
69
+ audit = subparsers.add_parser(
70
+ "audit",
71
+ help="scan every package in a lockfile (requirements.txt / poetry.lock) [M2]",
72
+ )
73
+ audit.add_argument("lockfile", type=Path, help="path to the lockfile")
74
+
75
+ return parser
76
+
77
+
78
+ def run(argv: list[str] | None = None, registry: Registry | None = None) -> int:
79
+ """Parse arguments and execute; ``registry`` is injectable for tests."""
80
+ args = build_parser().parse_args(argv)
81
+
82
+ if args.command == "audit":
83
+ print(
84
+ "phantom audit is not implemented yet (planned for M2).",
85
+ file=sys.stderr,
86
+ )
87
+ return EXIT_ERROR
88
+
89
+ try:
90
+ package, version = _parse_spec(args.spec)
91
+ except ValueError as exc:
92
+ print(f"error: {exc}", file=sys.stderr)
93
+ return EXIT_ERROR
94
+
95
+ if registry is None:
96
+ cache = DiskCache(args.cache_dir or default_cache_dir())
97
+ registry = build_default_registry(cache)
98
+
99
+ try:
100
+ ecosystem = registry.get(args.ecosystem)
101
+ result = core.scan(package, version, ecosystem)
102
+ except OutOfScopeError as exc:
103
+ print(f"out of scope: {exc}", file=sys.stderr)
104
+ return EXIT_OUT_OF_SCOPE
105
+ except PhantomError as exc:
106
+ print(f"error: {exc}", file=sys.stderr)
107
+ return EXIT_ERROR
108
+
109
+ if args.json:
110
+ print(json_report.render(result))
111
+ elif args.sarif:
112
+ print(sarif_report.render(result))
113
+ else:
114
+ print(table_report.render(result))
115
+
116
+ return core.exit_code_for(result)
117
+
118
+
119
+ def _parse_spec(spec: str) -> tuple[str, str]:
120
+ package, sep, version = spec.partition("==")
121
+ if not sep or not package or not version:
122
+ raise ValueError(
123
+ f"invalid spec {spec!r}: expected exact pin <pkg>==<version>"
124
+ )
125
+ return package.strip(), version.strip()
126
+
127
+
128
+ def main() -> None:
129
+ sys.exit(run())
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
@@ -0,0 +1,81 @@
1
+ """Orchestrator and library entry point: drives the ``Ecosystem`` interfaces
2
+ only, holds no state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from phantom import differ
7
+ from phantom.ecosystems.base import Ecosystem
8
+ from phantom.models import (
9
+ Confidence,
10
+ Finding,
11
+ FindingType,
12
+ NoSource,
13
+ ScanResult,
14
+ Severity,
15
+ SourceStatus,
16
+ )
17
+
18
+
19
+ def scan(package: str, version: str, ecosystem: Ecosystem) -> ScanResult:
20
+ """Scan one package version for source/artifact divergence.
21
+
22
+ Propagates fetcher errors; unresolvable source is a finding, not an error.
23
+ """
24
+ artifact = ecosystem.fetcher.fetch_artifact(package, version)
25
+ source = ecosystem.source_resolver.resolve_source(
26
+ package, version, artifact.metadata
27
+ )
28
+
29
+ if isinstance(source, NoSource):
30
+ return _no_source_result(package, version, ecosystem.name, source)
31
+
32
+ outcome = differ.diff(artifact, source, ecosystem.normalizers)
33
+ return ScanResult(
34
+ package=package,
35
+ version=version,
36
+ ecosystem=ecosystem.name,
37
+ source_status=SourceStatus.RESOLVED,
38
+ source_repo=source.repo_url,
39
+ source_ref=source.ref,
40
+ findings=outcome.findings,
41
+ files_scanned=outcome.files_scanned,
42
+ )
43
+
44
+
45
+ def exit_code_for(result: ScanResult) -> int:
46
+ """Return 1 if any finding is high or critical, else 0."""
47
+ if any(f.severity in (Severity.HIGH, Severity.CRITICAL) for f in result.findings):
48
+ return 1
49
+ return 0
50
+
51
+
52
+ def _no_source_result(
53
+ package: str, version: str, ecosystem: str, source: NoSource
54
+ ) -> ScanResult:
55
+ if source.finding_type == FindingType.NO_SOURCE_DECLARED:
56
+ # Unverifiable by construction: a risk in itself.
57
+ status = SourceStatus.NO_SOURCE_DECLARED
58
+ severity = Severity.HIGH
59
+ confidence = Confidence.HIGH
60
+ else:
61
+ # A repo exists but no tag convention matched; flag softly.
62
+ status = SourceStatus.REF_NOT_FOUND
63
+ severity = Severity.MEDIUM
64
+ confidence = Confidence.MEDIUM
65
+ finding = Finding(
66
+ type=source.finding_type,
67
+ path=None,
68
+ severity=severity,
69
+ confidence=confidence,
70
+ reason=source.detail,
71
+ )
72
+ return ScanResult(
73
+ package=package,
74
+ version=version,
75
+ ecosystem=ecosystem,
76
+ source_status=status,
77
+ source_repo=source.repo_url,
78
+ source_ref=None,
79
+ findings=[finding],
80
+ files_scanned=0,
81
+ )