phantom-scan 0.1.0__py3-none-any.whl
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/__init__.py +12 -0
- phantom/cache.py +63 -0
- phantom/cli.py +133 -0
- phantom/core.py +81 -0
- phantom/differ.py +129 -0
- phantom/ecosystems/__init__.py +3 -0
- phantom/ecosystems/base.py +54 -0
- phantom/ecosystems/npm.py +9 -0
- phantom/ecosystems/pypi.py +203 -0
- phantom/errors.py +19 -0
- phantom/models.py +148 -0
- phantom/normalizers/__init__.py +4 -0
- phantom/normalizers/base.py +18 -0
- phantom/normalizers/python_ast.py +24 -0
- phantom/registry.py +35 -0
- phantom/report/__init__.py +5 -0
- phantom/report/json.py +11 -0
- phantom/report/sarif.py +73 -0
- phantom/report/table.py +56 -0
- phantom/risk.py +106 -0
- phantom_scan-0.1.0.dist-info/METADATA +155 -0
- phantom_scan-0.1.0.dist-info/RECORD +25 -0
- phantom_scan-0.1.0.dist-info/WHEEL +5 -0
- phantom_scan-0.1.0.dist-info/entry_points.txt +2 -0
- phantom_scan-0.1.0.dist-info/top_level.txt +1 -0
phantom/__init__.py
ADDED
|
@@ -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"
|
phantom/cache.py
ADDED
|
@@ -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
|
phantom/cli.py
ADDED
|
@@ -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()
|
phantom/core.py
ADDED
|
@@ -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
|
+
)
|
phantom/differ.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Compares artifact files against a source hash index.
|
|
2
|
+
|
|
3
|
+
Content-based and path-agnostic: a wheel file matches if its normalized hash
|
|
4
|
+
exists anywhere in the source tree, so build-time moves and renames do not
|
|
5
|
+
cause false positives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from phantom import risk
|
|
13
|
+
from phantom.models import (
|
|
14
|
+
Artifact,
|
|
15
|
+
Confidence,
|
|
16
|
+
FileEntry,
|
|
17
|
+
Finding,
|
|
18
|
+
FindingType,
|
|
19
|
+
Severity,
|
|
20
|
+
SourceTree,
|
|
21
|
+
)
|
|
22
|
+
from phantom.normalizers.base import Normalizer
|
|
23
|
+
|
|
24
|
+
# Commonly generated at build time (setuptools-scm, hatch-vcs); a phantom
|
|
25
|
+
# hit on these gets low confidence.
|
|
26
|
+
_LIKELY_GENERATED_BASENAMES = {"_version.py", "version.py", "_version_meta.py"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class DiffOutcome:
|
|
31
|
+
findings: list[Finding]
|
|
32
|
+
files_scanned: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def diff(
|
|
36
|
+
artifact: Artifact, source: SourceTree, normalizers: list[Normalizer]
|
|
37
|
+
) -> DiffOutcome:
|
|
38
|
+
source_hashes = _build_index(source.files, normalizers)
|
|
39
|
+
findings: list[Finding] = []
|
|
40
|
+
files_scanned = 0
|
|
41
|
+
|
|
42
|
+
for file in artifact.files:
|
|
43
|
+
if _is_packaging_metadata(file.path):
|
|
44
|
+
continue
|
|
45
|
+
if file.path.endswith(".pth"):
|
|
46
|
+
files_scanned += 1
|
|
47
|
+
findings.append(_pth_finding(file))
|
|
48
|
+
continue
|
|
49
|
+
normalizer = _normalizer_for(file, normalizers)
|
|
50
|
+
if normalizer is None:
|
|
51
|
+
continue
|
|
52
|
+
files_scanned += 1
|
|
53
|
+
if normalizer.normalized_hash(file) in source_hashes:
|
|
54
|
+
continue
|
|
55
|
+
findings.append(_phantom_finding(file, source))
|
|
56
|
+
|
|
57
|
+
findings.sort(key=lambda f: (-f.severity.rank, f.path or ""))
|
|
58
|
+
return DiffOutcome(findings=findings, files_scanned=files_scanned)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_index(files: list[FileEntry], normalizers: list[Normalizer]) -> set[str]:
|
|
62
|
+
index: set[str] = set()
|
|
63
|
+
for file in files:
|
|
64
|
+
normalizer = _normalizer_for(file, normalizers)
|
|
65
|
+
if normalizer is not None:
|
|
66
|
+
index.add(normalizer.normalized_hash(file))
|
|
67
|
+
return index
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalizer_for(
|
|
71
|
+
file: FileEntry, normalizers: list[Normalizer]
|
|
72
|
+
) -> Normalizer | None:
|
|
73
|
+
for normalizer in normalizers:
|
|
74
|
+
if normalizer.applies_to(file):
|
|
75
|
+
return normalizer
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_packaging_metadata(path: str) -> bool:
|
|
80
|
+
first = path.split("/", 1)[0]
|
|
81
|
+
return first.endswith(".dist-info") or first.endswith(".data")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _pth_finding(file: FileEntry) -> Finding:
|
|
85
|
+
# .pth import lines execute at every interpreter startup.
|
|
86
|
+
has_import = any(
|
|
87
|
+
line.lstrip().startswith("import ")
|
|
88
|
+
for line in file.data.decode("utf-8", errors="replace").splitlines()
|
|
89
|
+
)
|
|
90
|
+
return Finding(
|
|
91
|
+
type=FindingType.SUSPICIOUS_PTH,
|
|
92
|
+
path=file.path,
|
|
93
|
+
severity=Severity.CRITICAL if has_import else Severity.HIGH,
|
|
94
|
+
confidence=Confidence.HIGH,
|
|
95
|
+
reason=(
|
|
96
|
+
".pth file shipped inside a wheel; "
|
|
97
|
+
+ (
|
|
98
|
+
"it contains import lines that execute code at every "
|
|
99
|
+
"interpreter startup"
|
|
100
|
+
if has_import
|
|
101
|
+
else "legitimate wheels almost never include .pth files"
|
|
102
|
+
)
|
|
103
|
+
),
|
|
104
|
+
execution_vectors=["startup:.pth-import"] if has_import else [],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _phantom_finding(file: FileEntry, source: SourceTree) -> Finding:
|
|
109
|
+
assessment = risk.assess(file)
|
|
110
|
+
basename = file.path.rsplit("/", 1)[-1]
|
|
111
|
+
confidence = Confidence.HIGH
|
|
112
|
+
reason = (
|
|
113
|
+
f"content of {file.path} has no normalized-hash match anywhere in "
|
|
114
|
+
f"{source.repo_url}@{source.ref}; {assessment.detail}"
|
|
115
|
+
)
|
|
116
|
+
if basename in _LIKELY_GENERATED_BASENAMES:
|
|
117
|
+
confidence = Confidence.LOW
|
|
118
|
+
reason += (
|
|
119
|
+
"; note: this filename is commonly generated at build time "
|
|
120
|
+
"(e.g. setuptools-scm), so this may be legitimate generated code"
|
|
121
|
+
)
|
|
122
|
+
return Finding(
|
|
123
|
+
type=FindingType.PHANTOM_FILE,
|
|
124
|
+
path=file.path,
|
|
125
|
+
severity=assessment.severity,
|
|
126
|
+
confidence=confidence,
|
|
127
|
+
reason=reason,
|
|
128
|
+
execution_vectors=assessment.vectors,
|
|
129
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Ecosystem plugin contracts. ``phantom.core.scan()`` only speaks these
|
|
2
|
+
interfaces; adding an ecosystem never touches the core or the differ."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from phantom.models import Artifact, NoSource, SourceTree
|
|
9
|
+
from phantom.normalizers.base import Normalizer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Fetcher(ABC):
|
|
13
|
+
"""Obtains the published artifact for a package version."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def fetch_artifact(self, pkg: str, version: str) -> Artifact:
|
|
17
|
+
"""Download and unpack the artifact.
|
|
18
|
+
|
|
19
|
+
Raises ``OutOfScopeError`` if the release cannot be analyzed,
|
|
20
|
+
``NotFoundError`` if it does not exist, ``FetchError`` on network
|
|
21
|
+
failure.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SourceResolver(ABC):
|
|
26
|
+
"""Locates the source tree that should match the published version."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def resolve_source(
|
|
30
|
+
self, pkg: str, version: str, metadata: dict
|
|
31
|
+
) -> SourceTree | NoSource:
|
|
32
|
+
"""Resolve repo + ref from registry metadata and fetch the tree.
|
|
33
|
+
|
|
34
|
+
Returns ``NoSource`` instead of raising: unresolvable source is a
|
|
35
|
+
finding, not an error.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Ecosystem(ABC):
|
|
40
|
+
"""Bundle of everything needed to scan one packaging ecosystem."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def fetcher(self) -> Fetcher: ...
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def source_resolver(self) -> SourceResolver: ...
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def normalizers(self) -> list[Normalizer]: ...
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""npm ecosystem — stub, planned for M2 (FR-10).
|
|
2
|
+
|
|
3
|
+
Will implement a ``Fetcher`` for registry tarballs (``.tgz``), a
|
|
4
|
+
``SourceResolver`` for the ``repository`` field of ``package.json``, and
|
|
5
|
+
normalizers for JS minification/transpilation (M3). Registering it in
|
|
6
|
+
``phantom.registry`` is all the core needs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""PyPI ecosystem: wheels from the JSON API, sources from GitHub tag
|
|
2
|
+
tarballs. Only pure-Python wheels (``*-none-any.whl``) are in scope."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import tarfile
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
from phantom.cache import DiskCache, http_get
|
|
13
|
+
from phantom.ecosystems.base import Ecosystem, Fetcher, SourceResolver
|
|
14
|
+
from phantom.errors import FetchError, NotFoundError, OutOfScopeError
|
|
15
|
+
from phantom.models import Artifact, FileEntry, FindingType, NoSource, SourceTree
|
|
16
|
+
from phantom.normalizers.base import Normalizer
|
|
17
|
+
from phantom.normalizers.python_ast import PythonASTNormalizer
|
|
18
|
+
|
|
19
|
+
PYPI_JSON_URL = "https://pypi.org/pypi/{pkg}/{version}/json"
|
|
20
|
+
# codeload serves public tag tarballs without the API rate limit.
|
|
21
|
+
GITHUB_TARBALL_URL = "https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"
|
|
22
|
+
|
|
23
|
+
# Tag conventions tried in order.
|
|
24
|
+
TAG_PATTERNS = ("v{version}", "{version}", "release-{version}")
|
|
25
|
+
|
|
26
|
+
# project_urls keys checked in priority order.
|
|
27
|
+
_SOURCE_URL_KEYS = ("source", "source code", "repository", "code", "github", "homepage")
|
|
28
|
+
|
|
29
|
+
_GITHUB_RE = re.compile(
|
|
30
|
+
r"https?://(?:www\.)?github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_github_repo(url: str) -> tuple[str, str] | None:
|
|
35
|
+
match = _GITHUB_RE.match(url.strip())
|
|
36
|
+
if not match:
|
|
37
|
+
return None
|
|
38
|
+
owner = match.group("owner")
|
|
39
|
+
repo = match.group("repo").removesuffix(".git")
|
|
40
|
+
return owner, repo
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PyPIFetcher(Fetcher):
|
|
44
|
+
def __init__(self, cache: DiskCache | None = None) -> None:
|
|
45
|
+
self.cache = cache
|
|
46
|
+
|
|
47
|
+
def fetch_artifact(self, pkg: str, version: str) -> Artifact:
|
|
48
|
+
url = PYPI_JSON_URL.format(pkg=pkg, version=version)
|
|
49
|
+
try:
|
|
50
|
+
metadata = json.loads(http_get(url, self.cache))
|
|
51
|
+
except NotFoundError as exc:
|
|
52
|
+
raise NotFoundError(f"{pkg}=={version} not found on PyPI") from exc
|
|
53
|
+
|
|
54
|
+
releases = metadata.get("urls", [])
|
|
55
|
+
wheel = self._pick_pure_wheel(releases)
|
|
56
|
+
if wheel is None:
|
|
57
|
+
raise OutOfScopeError(self._out_of_scope_reason(pkg, version, releases))
|
|
58
|
+
|
|
59
|
+
data = http_get(wheel["url"], self.cache)
|
|
60
|
+
files = self._unpack_wheel(data)
|
|
61
|
+
return Artifact(
|
|
62
|
+
package=pkg,
|
|
63
|
+
version=version,
|
|
64
|
+
filename=wheel["filename"],
|
|
65
|
+
files=files,
|
|
66
|
+
metadata=metadata.get("info", {}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _pick_pure_wheel(releases: list[dict]) -> dict | None:
|
|
71
|
+
for release in releases:
|
|
72
|
+
if release.get("packagetype") == "bdist_wheel" and release.get(
|
|
73
|
+
"filename", ""
|
|
74
|
+
).endswith("-none-any.whl"):
|
|
75
|
+
return release
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _out_of_scope_reason(pkg: str, version: str, releases: list[dict]) -> str:
|
|
80
|
+
kinds = sorted({r.get("packagetype", "?") for r in releases})
|
|
81
|
+
if "bdist_wheel" in kinds:
|
|
82
|
+
detail = "only platform-specific (compiled) wheels are published"
|
|
83
|
+
elif "sdist" in kinds:
|
|
84
|
+
detail = "only an sdist is published"
|
|
85
|
+
else:
|
|
86
|
+
detail = "no distribution files found"
|
|
87
|
+
return (
|
|
88
|
+
f"{pkg}=={version}: {detail}. M1 only supports pure-Python wheels "
|
|
89
|
+
f"(*-none-any.whl)."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _unpack_wheel(data: bytes) -> list[FileEntry]:
|
|
94
|
+
try:
|
|
95
|
+
archive = zipfile.ZipFile(io.BytesIO(data))
|
|
96
|
+
except zipfile.BadZipFile as exc:
|
|
97
|
+
raise FetchError(f"downloaded wheel is not a valid zip: {exc}") from exc
|
|
98
|
+
entries = []
|
|
99
|
+
for info in archive.infolist():
|
|
100
|
+
if info.is_dir():
|
|
101
|
+
continue
|
|
102
|
+
entries.append(FileEntry(path=info.filename, data=archive.read(info)))
|
|
103
|
+
return entries
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PyPISourceResolver(SourceResolver):
|
|
107
|
+
def __init__(self, cache: DiskCache | None = None) -> None:
|
|
108
|
+
self.cache = cache
|
|
109
|
+
|
|
110
|
+
def resolve_source(
|
|
111
|
+
self, pkg: str, version: str, metadata: dict
|
|
112
|
+
) -> SourceTree | NoSource:
|
|
113
|
+
repo = self._find_github_repo(metadata)
|
|
114
|
+
if repo is None:
|
|
115
|
+
return NoSource(
|
|
116
|
+
finding_type=FindingType.NO_SOURCE_DECLARED,
|
|
117
|
+
detail=(
|
|
118
|
+
f"{pkg} declares no GitHub source repository in its PyPI "
|
|
119
|
+
f"metadata (project_urls/home_page). The published artifact "
|
|
120
|
+
f"cannot be verified against any source."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
owner, name = repo
|
|
124
|
+
repo_url = f"https://github.com/{owner}/{name}"
|
|
125
|
+
tried = []
|
|
126
|
+
for pattern in TAG_PATTERNS:
|
|
127
|
+
tag = pattern.format(version=version)
|
|
128
|
+
tried.append(tag)
|
|
129
|
+
try:
|
|
130
|
+
data = http_get(
|
|
131
|
+
GITHUB_TARBALL_URL.format(owner=owner, repo=name, tag=tag),
|
|
132
|
+
self.cache,
|
|
133
|
+
)
|
|
134
|
+
except NotFoundError:
|
|
135
|
+
continue
|
|
136
|
+
return SourceTree(repo_url=repo_url, ref=tag, files=_untar(data))
|
|
137
|
+
return NoSource(
|
|
138
|
+
finding_type=FindingType.SOURCE_REF_NOT_FOUND,
|
|
139
|
+
detail=(
|
|
140
|
+
f"no tag matching version {version} found in {repo_url} "
|
|
141
|
+
f"(tried: {', '.join(tried)})"
|
|
142
|
+
),
|
|
143
|
+
repo_url=repo_url,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _find_github_repo(metadata: dict) -> tuple[str, str] | None:
|
|
148
|
+
project_urls = {
|
|
149
|
+
key.lower(): value
|
|
150
|
+
for key, value in (metadata.get("project_urls") or {}).items()
|
|
151
|
+
if value
|
|
152
|
+
}
|
|
153
|
+
candidates = [
|
|
154
|
+
project_urls[key] for key in _SOURCE_URL_KEYS if key in project_urls
|
|
155
|
+
]
|
|
156
|
+
candidates += [v for v in project_urls.values() if v not in candidates]
|
|
157
|
+
if metadata.get("home_page"):
|
|
158
|
+
candidates.append(metadata["home_page"])
|
|
159
|
+
for url in candidates:
|
|
160
|
+
repo = _parse_github_repo(url)
|
|
161
|
+
if repo is not None:
|
|
162
|
+
return repo
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _untar(data: bytes) -> list[FileEntry]:
|
|
167
|
+
"""Extract a tarball in memory, stripping GitHub's top-level directory."""
|
|
168
|
+
try:
|
|
169
|
+
archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
|
|
170
|
+
except tarfile.TarError as exc:
|
|
171
|
+
raise FetchError(f"downloaded source tarball is invalid: {exc}") from exc
|
|
172
|
+
entries = []
|
|
173
|
+
with archive:
|
|
174
|
+
for member in archive.getmembers():
|
|
175
|
+
if not member.isfile():
|
|
176
|
+
continue
|
|
177
|
+
path = member.name.split("/", 1)[1] if "/" in member.name else member.name
|
|
178
|
+
handle = archive.extractfile(member)
|
|
179
|
+
if handle is None:
|
|
180
|
+
continue
|
|
181
|
+
entries.append(FileEntry(path=path, data=handle.read()))
|
|
182
|
+
return entries
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class PyPIEcosystem(Ecosystem):
|
|
186
|
+
name = "pypi"
|
|
187
|
+
|
|
188
|
+
def __init__(self, cache: DiskCache | None = None) -> None:
|
|
189
|
+
self._fetcher = PyPIFetcher(cache)
|
|
190
|
+
self._source_resolver = PyPISourceResolver(cache)
|
|
191
|
+
self._normalizers: list[Normalizer] = [PythonASTNormalizer()]
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def fetcher(self) -> Fetcher:
|
|
195
|
+
return self._fetcher
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def source_resolver(self) -> SourceResolver:
|
|
199
|
+
return self._source_resolver
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def normalizers(self) -> list[Normalizer]:
|
|
203
|
+
return self._normalizers
|