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.
- phantom_scan-0.1.0/PKG-INFO +155 -0
- phantom_scan-0.1.0/README.md +143 -0
- phantom_scan-0.1.0/phantom/__init__.py +12 -0
- phantom_scan-0.1.0/phantom/cache.py +63 -0
- phantom_scan-0.1.0/phantom/cli.py +133 -0
- phantom_scan-0.1.0/phantom/core.py +81 -0
- phantom_scan-0.1.0/phantom/differ.py +129 -0
- phantom_scan-0.1.0/phantom/ecosystems/__init__.py +3 -0
- phantom_scan-0.1.0/phantom/ecosystems/base.py +54 -0
- phantom_scan-0.1.0/phantom/ecosystems/npm.py +9 -0
- phantom_scan-0.1.0/phantom/ecosystems/pypi.py +203 -0
- phantom_scan-0.1.0/phantom/errors.py +19 -0
- phantom_scan-0.1.0/phantom/models.py +148 -0
- phantom_scan-0.1.0/phantom/normalizers/__init__.py +4 -0
- phantom_scan-0.1.0/phantom/normalizers/base.py +18 -0
- phantom_scan-0.1.0/phantom/normalizers/python_ast.py +24 -0
- phantom_scan-0.1.0/phantom/registry.py +35 -0
- phantom_scan-0.1.0/phantom/report/__init__.py +5 -0
- phantom_scan-0.1.0/phantom/report/json.py +11 -0
- phantom_scan-0.1.0/phantom/report/sarif.py +73 -0
- phantom_scan-0.1.0/phantom/report/table.py +56 -0
- phantom_scan-0.1.0/phantom/risk.py +106 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/PKG-INFO +155 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/SOURCES.txt +35 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/dependency_links.txt +1 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/entry_points.txt +2 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/requires.txt +3 -0
- phantom_scan-0.1.0/phantom_scan.egg-info/top_level.txt +1 -0
- phantom_scan-0.1.0/pyproject.toml +32 -0
- phantom_scan-0.1.0/setup.cfg +4 -0
- phantom_scan-0.1.0/tests/test_cli.py +73 -0
- phantom_scan-0.1.0/tests/test_network.py +43 -0
- phantom_scan-0.1.0/tests/test_normalizer.py +33 -0
- phantom_scan-0.1.0/tests/test_pypi.py +75 -0
- phantom_scan-0.1.0/tests/test_reports.py +67 -0
- phantom_scan-0.1.0/tests/test_risk.py +41 -0
- 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
|
+
)
|