Package scanners: cut false positives and make the CI gate blocking (#6355)
* Package scanners: cut false positives and make the CI gate blocking scan_packages.py and scan_npm_packages.py red-failed on legitimate library code, so the security-audit steps were left advisory. Reduce the false positives at the source and flip both gates to blocking. scan_packages.py: - Scan code only: blank comments and bare docstrings/doctests before matching (line numbers preserved), so prose and >>> examples cannot trip a finding. - Drop the platform.system() branch from the anti-analysis regex (under DOTALL it matched across the whole file, so every cross-platform library tripped it) and fix the dead /proc/self/status alternative. - Add a reviewed baseline allowlist (scan_packages_baseline.json) keyed on (package, basename, check): only non-baselined CRITICAL/HIGH exit 1, and a new kind of finding in a listed file still fails. - sdist fallback: when --with-deps cannot resolve a shard (a sdist-only package or a version conflict), drop to per-spec and fetch the raw sdist from the PyPI JSON API (no pip build, no setup.py), so every package is still scanned and no shard exits 2. scan_npm_packages.py: - Mirror the code-only JS/TS scanning (blank // and /* */ comments, string/template/regex aware) and the baseline allowlist. The npm corpus is clean today, so the baseline is empty. security-audit.yml: - Flip both scan steps to blocking (SCAN_ENFORCE=1), capturing the scanner exit via PIPESTATUS so tee does not mask it. tests/security: add coverage for the strip, baseline and sdist paths. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review feedback on the package scanners - Do not blank f-strings during code-only scanning (they evaluate at import); and when a file uses exec/eval, rescan the original for payload carriers hidden in a docstring/string so exec(__doc__) style payloads stay visible. - sdist fallback: recover transitive deps with their version specifier (fetch the pinned version, not latest), and recover deps in the --no-deps branch too so a sdist-only transitive dependency is still scanned instead of silently skipped. - Baseline: key by package-relative path, not basename, so a future same-named file in another directory is not auto-suppressed. Regenerated the baseline accordingly. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
5a38447b25
commit
21612c2e32
7 changed files with 2951 additions and 79 deletions
|
|
@ -412,7 +412,7 @@ BLOCKED_NPM_VERSIONS: dict[str, set[str]] = {
|
|||
"@uipath/functions-tool": {"1.0.1"},
|
||||
"@uipath/access-policy-sdk": {"0.3.1"},
|
||||
"@uipath/platform-tool": {"1.0.1"},
|
||||
# Mini Shai-Hulud May-12 wave: @mistralai/* (npm) — separate from PyPI mistralai
|
||||
# Mini Shai-Hulud May-12 wave: @mistralai/* (npm), separate from PyPI mistralai
|
||||
# (https://www.aikido.dev/blog/mini-shai-hulud-is-back-tanstack-compromised).
|
||||
"@mistralai/mistralai": {"2.2.2", "2.2.3", "2.2.4"},
|
||||
"@mistralai/mistralai-gcp": {"1.7.1", "1.7.2", "1.7.3"},
|
||||
|
|
@ -916,6 +916,204 @@ def _evidence(
|
|||
LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Code-only scanning for JS/TS sources. Blank `//` and `/* */` comments
|
||||
# before matching (the top FP source: scary strings in JSDoc/changelog
|
||||
# comments), tracking string/template/regex context so a `//` inside
|
||||
# "http://..." is not mistaken for a comment. Strings are NOT blanked
|
||||
# (droppers hide payloads there). Fail open on lexer confusion: the raw
|
||||
# text is still scanned. JS sibling of scan_packages.py::_strip_noncode.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
_JS_FAMILY_SUFFIXES = (".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")
|
||||
|
||||
# Keywords after which a `/` begins a regex literal (not division).
|
||||
_REGEX_PRECEDING_KEYWORDS = frozenset(
|
||||
{
|
||||
"return",
|
||||
"typeof",
|
||||
"instanceof",
|
||||
"in",
|
||||
"of",
|
||||
"new",
|
||||
"delete",
|
||||
"void",
|
||||
"throw",
|
||||
"yield",
|
||||
"await",
|
||||
"do",
|
||||
"else",
|
||||
"case",
|
||||
}
|
||||
)
|
||||
_IDENT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$")
|
||||
|
||||
|
||||
def _slash_is_regex(prev_tok: str) -> bool:
|
||||
"""Disambiguate a lone ``/``: regex literal vs division operator.
|
||||
|
||||
Biased toward regex when ambiguous -- regex state never blanks, so a
|
||||
wrong guess only costs FP reduction (or a fail-open), never a missed
|
||||
detection.
|
||||
"""
|
||||
if prev_tok == "":
|
||||
return True # start of file -> expression position
|
||||
if prev_tok in _REGEX_PRECEDING_KEYWORDS:
|
||||
return True
|
||||
last = prev_tok[-1]
|
||||
if last.isalnum() or last in "_$)]":
|
||||
return False # previous token ends a value -> division
|
||||
return True # operators, punctuation, `{`, `}` -> regex (safe bias)
|
||||
|
||||
|
||||
def _strip_js_noncode(text: str) -> str:
|
||||
"""Blank JS/TS comments, preserving byte geometry. Fail-open on confusion."""
|
||||
if "//" not in text and "/*" not in text:
|
||||
return text # nothing to strip
|
||||
n = len(text)
|
||||
out = list(text)
|
||||
nl = ("\n", "\r")
|
||||
|
||||
def _blank(a: int, b: int) -> None:
|
||||
for k in range(a, b):
|
||||
if out[k] not in nl:
|
||||
out[k] = " "
|
||||
|
||||
state = "code"
|
||||
prev_tok = ""
|
||||
tmpl_stack: list[str] = []
|
||||
i = 0
|
||||
try:
|
||||
while i < n:
|
||||
c = text[i]
|
||||
nxt = text[i + 1] if i + 1 < n else ""
|
||||
if state == "code":
|
||||
if c == "/" and nxt == "/":
|
||||
start = i
|
||||
i += 2
|
||||
while i < n and text[i] not in nl:
|
||||
i += 1
|
||||
_blank(start, i)
|
||||
continue
|
||||
if c == "/" and nxt == "*":
|
||||
start = i
|
||||
i += 2
|
||||
closed = False
|
||||
while i < n:
|
||||
if text[i] == "*" and i + 1 < n and text[i + 1] == "/":
|
||||
i += 2
|
||||
closed = True
|
||||
break
|
||||
i += 1
|
||||
if not closed:
|
||||
return text # unterminated block comment
|
||||
_blank(start, i)
|
||||
continue
|
||||
if c == "'":
|
||||
state = "sq"
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
state = "dq"
|
||||
i += 1
|
||||
continue
|
||||
if c == "`":
|
||||
state = "tmpl"
|
||||
i += 1
|
||||
continue
|
||||
if c == "/":
|
||||
if _slash_is_regex(prev_tok):
|
||||
state = "regex"
|
||||
i += 1
|
||||
continue
|
||||
prev_tok = "/"
|
||||
i += 1
|
||||
continue
|
||||
if c.isspace():
|
||||
i += 1
|
||||
continue
|
||||
if c in _IDENT_CHARS:
|
||||
j = i
|
||||
while j < n and text[j] in _IDENT_CHARS:
|
||||
j += 1
|
||||
prev_tok = text[i:j]
|
||||
i = j
|
||||
continue
|
||||
if c == "}" and tmpl_stack:
|
||||
state = tmpl_stack.pop()
|
||||
i += 1
|
||||
continue
|
||||
prev_tok = c
|
||||
i += 1
|
||||
continue
|
||||
elif state in ("sq", "dq"):
|
||||
q = "'" if state == "sq" else '"'
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == q:
|
||||
state = "code"
|
||||
prev_tok = "_v"
|
||||
i += 1
|
||||
continue
|
||||
if c in nl:
|
||||
return text # unterminated string literal
|
||||
i += 1
|
||||
continue
|
||||
elif state == "tmpl":
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "`":
|
||||
state = "code"
|
||||
prev_tok = "_v"
|
||||
i += 1
|
||||
continue
|
||||
if c == "$" and nxt == "{":
|
||||
tmpl_stack.append("tmpl")
|
||||
state = "code"
|
||||
prev_tok = "{"
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
elif state == "regex":
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "[":
|
||||
state = "regex_cc"
|
||||
i += 1
|
||||
continue
|
||||
if c == "/":
|
||||
state = "code"
|
||||
prev_tok = "_v"
|
||||
i += 1
|
||||
continue
|
||||
if c in nl:
|
||||
return text # unterminated regex literal
|
||||
i += 1
|
||||
continue
|
||||
elif state == "regex_cc":
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if c == "]":
|
||||
state = "regex"
|
||||
i += 1
|
||||
continue
|
||||
if c in nl:
|
||||
return text
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
return text
|
||||
if state != "code" or tmpl_stack:
|
||||
return text # unterminated construct -> fail open
|
||||
except Exception:
|
||||
return text
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
try:
|
||||
|
|
@ -1042,6 +1240,14 @@ def _host_in_outbound_context(text: str, host: str) -> bool:
|
|||
def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
# Code-only scanning for JS/TS sources: blank comments before matching so
|
||||
# an IOC host / `eval(atob)` example / campaign marker quoted in a comment
|
||||
# cannot manufacture a false positive. Assigned string literals (where real
|
||||
# droppers hide base64 payloads) are preserved. Non-JS text (json/yaml/sh/
|
||||
# py/html) is scanned as-is -- this lexer only understands JS comments.
|
||||
if rel.lower().endswith(_JS_FAMILY_SUFFIXES):
|
||||
text = _strip_js_noncode(text)
|
||||
|
||||
# IOC substrings (literal, case-sensitive).
|
||||
for needle, (sev, why) in KNOWN_IOC_STRINGS.items():
|
||||
if needle in text:
|
||||
|
|
@ -1236,6 +1442,105 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N
|
|||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Baseline allowlist: triaged known-good HIGH/CRITICAL findings so the gate
|
||||
# can enforce without red-failing on rare legitimate-library behavior.
|
||||
# Matched on ``(normalized package, basename(filename), pattern)`` -- not
|
||||
# evidence text -- so a version bump does not reopen a finding, but a *new*
|
||||
# kind of finding in a listed file is a different pattern and still fails.
|
||||
# Mirrors scan_packages.py. Regenerate with ``--write-baseline``.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_DEFAULT_BASELINE_PATH = str(Path(__file__).resolve().parent / "scan_npm_packages_baseline.json")
|
||||
|
||||
|
||||
def _norm_pkg_name(display: str) -> str:
|
||||
"""``@scope/pkg@1.2.3`` / ``pkg@1.2.3`` -> name without the version.
|
||||
|
||||
The version is the LAST ``@``-separated field; a leading ``@`` (scope)
|
||||
is preserved. Lower-cased (npm names are case-insensitive). Sentinels
|
||||
like ``<root>`` / ``<lockfile>`` pass through unchanged.
|
||||
"""
|
||||
s = (display or "").strip()
|
||||
at = s.rfind("@")
|
||||
if at > 0: # >0 so a leading @scope is not treated as the version sep
|
||||
s = s[:at]
|
||||
return s.lower()
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str]:
|
||||
"""Stable allowlist key: normalized package, file basename, pattern."""
|
||||
return (_norm_pkg_name(f.package), os.path.basename(f.filename), f.pattern)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
"""Load an allowlist JSON into a set of match keys. Missing file -> empty."""
|
||||
try:
|
||||
with open(path, "r", encoding = "utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return set()
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str]] = set()
|
||||
for e in data.get("entries", []):
|
||||
try:
|
||||
keys.add((_norm_pkg_name(e["package"]), os.path.basename(e["file"]), e["pattern"]))
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
return keys
|
||||
|
||||
|
||||
def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) -> int:
|
||||
"""Persist at-or-above-threshold findings as an allowlist for triage."""
|
||||
entries = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for f in sorted(findings, key = lambda f: (_SEVERITY_RANK[f.severity], f.package)):
|
||||
if _SEVERITY_RANK[f.severity] > threshold_rank:
|
||||
continue
|
||||
key = _finding_key(f)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entries.append(
|
||||
{
|
||||
"package": _norm_pkg_name(f.package),
|
||||
"file": os.path.basename(f.filename),
|
||||
"pattern": f.pattern,
|
||||
"severity": f.severity,
|
||||
"evidence": (f.evidence or f.detail)[:240],
|
||||
}
|
||||
)
|
||||
doc = {
|
||||
"_comment": (
|
||||
"scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL "
|
||||
"finding manually judged benign. Matched on (package, "
|
||||
"basename(file), pattern); evidence/severity are for review only. "
|
||||
"Regenerate with --write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": 1,
|
||||
"entries": entries,
|
||||
}
|
||||
with open(path, "w", encoding = "utf-8") as fh:
|
||||
json.dump(doc, fh, indent = 2, sort_keys = False)
|
||||
fh.write("\n")
|
||||
print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}")
|
||||
return len(entries)
|
||||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
return list(findings), []
|
||||
active, suppressed = [], []
|
||||
for f in findings:
|
||||
(suppressed if _finding_key(f) in baseline else active).append(f)
|
||||
return active, suppressed
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description = "Pre-install npm tarball content scanner.",
|
||||
|
|
@ -1263,6 +1568,30 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"Medium and below print but exit 0."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
metavar = "FILE",
|
||||
default = None,
|
||||
help = (
|
||||
"Allowlist JSON of triaged known-good findings to suppress. "
|
||||
"Defaults to scan_npm_packages_baseline.json next to this script "
|
||||
"if present."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-baseline",
|
||||
action = "store_true",
|
||||
help = "Ignore the auto-discovered baseline allowlist.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write-baseline",
|
||||
metavar = "FILE",
|
||||
default = None,
|
||||
help = (
|
||||
"Write the current at/above-threshold findings to FILE as an "
|
||||
"allowlist, then exit 0. Review every entry before committing it."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
lockfile = Path(args.lockfile).resolve()
|
||||
|
|
@ -1341,7 +1670,46 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"critical": CRITICAL,
|
||||
}[args.fail_on]
|
||||
threshold_rank = _SEVERITY_RANK[threshold]
|
||||
blocking = [f for f in all_findings if _SEVERITY_RANK[f.severity] <= threshold_rank]
|
||||
|
||||
# --write-baseline: persist the full current at/above-threshold set as the
|
||||
# new allowlist (ignoring any loaded baseline), then exit 0. A hard error
|
||||
# means the scan was incomplete, so warn -- a baseline baked from a partial
|
||||
# run would silently allow whatever failed to download.
|
||||
if args.write_baseline:
|
||||
if hard_errors:
|
||||
print(
|
||||
f" [WARN] {len(hard_errors)} hard error(s): baseline may be "
|
||||
"incomplete (some packages did not scan).",
|
||||
file = sys.stderr,
|
||||
)
|
||||
_write_baseline(args.write_baseline, all_findings, threshold_rank)
|
||||
return 0
|
||||
|
||||
# Baseline allowlist: suppress triaged, known-good findings so the CI gate
|
||||
# can be enforcing without red-failing on legitimate-library noise.
|
||||
if args.no_baseline:
|
||||
baseline_path = None
|
||||
elif args.baseline:
|
||||
baseline_path = args.baseline
|
||||
elif os.path.isfile(_DEFAULT_BASELINE_PATH):
|
||||
baseline_path = _DEFAULT_BASELINE_PATH
|
||||
else:
|
||||
baseline_path = None
|
||||
baseline = _load_baseline(baseline_path) if baseline_path else set()
|
||||
active, suppressed = _partition_baseline(all_findings, baseline)
|
||||
|
||||
if suppressed:
|
||||
crit_s = sum(1 for f in suppressed if f.severity == CRITICAL)
|
||||
high_s = sum(1 for f in suppressed if f.severity == HIGH)
|
||||
print(
|
||||
f"\n[scan-npm] {len(suppressed)} finding(s) suppressed by baseline "
|
||||
f"{baseline_path} ({crit_s} CRITICAL, {high_s} HIGH).",
|
||||
flush = True,
|
||||
)
|
||||
|
||||
# Exit code: 1 on a hard error, or a NON-baselined finding at/above the
|
||||
# threshold. This is the signal CI gates on once the baseline is clean.
|
||||
blocking = [f for f in active if _SEVERITY_RANK[f.severity] <= threshold_rank]
|
||||
if hard_errors or blocking:
|
||||
if blocking:
|
||||
print(
|
||||
|
|
|
|||
5
scripts/scan_npm_packages_baseline.json
Normal file
5
scripts/scan_npm_packages_baseline.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, basename(file), pattern); evidence/severity are for review only. Regenerate with --write-baseline AFTER reviewing every line. EMPTY by design: a full scan of studio/frontend/package-lock.json (915 packages) produced 0 findings, so nothing needs suppressing and the CI gate can run enforcing (SCAN_ENFORCE=1) as-is. If a future dependency adds a reviewed-benign HIGH/CRITICAL, add it here rather than weakening a pattern.",
|
||||
"version": 1,
|
||||
"entries": []
|
||||
}
|
||||
|
|
@ -33,10 +33,24 @@ Examples:
|
|||
python scan_packages.py --fix -r requirements.txt
|
||||
python scan_packages.py --fix --max-search 20 -r requirements.txt
|
||||
|
||||
# Triage to a baseline once, then gate on anything NEW
|
||||
python scan_packages.py -r requirements.txt --write-baseline scripts/scan_packages_baseline.json
|
||||
python scan_packages.py -r requirements.txt # auto-loads the baseline, exits 0 if only baselined findings remain
|
||||
|
||||
False positives:
|
||||
.py files are scanned code-only: comments and bare docstrings/doctests are
|
||||
blanked before pattern matching (line numbers preserved), so prose, usage
|
||||
examples and `>>>` doctests cannot trip a finding. Residual findings that
|
||||
are genuine library behavior (a HTTP client reading HF_TOKEN, a vendored
|
||||
test fixture) are suppressed via a reviewed baseline allowlist, matched on
|
||||
(package, basename(file), check). A NEW kind of finding in an already-listed
|
||||
file is a different check and still fails. This mirrors the Hugging Face Hub
|
||||
approach (ClamAV/picklescan: low-FP, signature/structural, surface status).
|
||||
|
||||
Exit codes:
|
||||
0 -- no CRITICAL or HIGH findings
|
||||
1 -- CRITICAL or HIGH findings detected
|
||||
2 -- no packages specified
|
||||
0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline)
|
||||
1 -- non-baselined CRITICAL or HIGH findings detected
|
||||
2 -- no packages specified, or scan incomplete (pip download failure)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
|
@ -50,6 +64,8 @@ import subprocess
|
|||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import tokenize
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
|
|
@ -213,17 +229,26 @@ RE_ARCHIVE_STAGING = re.compile(
|
|||
)
|
||||
|
||||
# Anti-analysis / sandbox evasion / debugger detection
|
||||
# NB: deliberately does NOT include a bare ``platform.system() ... Linux/Windows
|
||||
# /Darwin`` branch. Under re.DOTALL that matched across the whole file -- any
|
||||
# cross-platform library (typer, packaging, pandas, pymupdf, ...) trips it -- so
|
||||
# it had ~zero precision and only generated false positives. OS detection alone
|
||||
# is not an anti-analysis signal; the debugger/VM/long-sleep signals below are.
|
||||
RE_ANTI_ANALYSIS = re.compile(
|
||||
r"\bptrace\b"
|
||||
r"|\bsys\s*\.\s*gettrace\s*\("
|
||||
r"|\bsys\s*\.\s*settrace\b"
|
||||
r"|\bTracerPid\b"
|
||||
r"|\b/proc/self/status\b"
|
||||
# /proc/self/status is read to scrape TracerPid for anti-debug. A leading
|
||||
# \b here is unsatisfiable (\b never holds between a non-word boundary and
|
||||
# "/"), so the old pattern was dead; a lookbehind that only forbids a
|
||||
# preceding word char or path separator lets `open("/proc/self/status")`
|
||||
# and `cat /proc/self/status` match while avoiding mid-path partials.
|
||||
r"|(?<![\w/])/proc/self/status\b"
|
||||
r"|\bIsDebuggerPresent\b"
|
||||
r"|\bvirtualbox\b.*\bhardware\b"
|
||||
r"|\bvmware\b.*\bdetect\b"
|
||||
r"|\btime\.sleep\s*\(\s*(?:[3-9]\d{2,}|[1-9]\d{3,})\s*\)" # long sleep (anti-sandbox)
|
||||
r"|\bplatform\.\s*system\b.*\bif\b.*\b(?:Linux|Windows|Darwin)\b",
|
||||
r"|\btime\.sleep\s*\(\s*(?:[3-9]\d{2,}|[1-9]\d{3,})\s*\)", # long sleep (anti-sandbox)
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
|
@ -493,9 +518,130 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
return findings
|
||||
|
||||
|
||||
# A STRING after one of these tokens (and before a NEWLINE) is a bare
|
||||
# docstring/doctest/prose statement -- the dominant FP source -- so we blank it.
|
||||
# A string after `=` or `(` is real code and is never blanked.
|
||||
_LINE_START_TOKENS = frozenset({tokenize.NEWLINE, tokenize.NL, tokenize.INDENT, tokenize.DEDENT})
|
||||
|
||||
|
||||
def _is_fstring(tok_string: str) -> bool:
|
||||
"""True if a STRING token is an f-string (3.10/3.11 emit one STRING token).
|
||||
|
||||
A bare f-string statement evaluates its expressions at import, so unlike an
|
||||
inert docstring it must never be blanked.
|
||||
"""
|
||||
q = min((tok_string.find(c) for c in "'\"" if c in tok_string), default = -1)
|
||||
return q > 0 and "f" in tok_string[:q].lower()
|
||||
|
||||
|
||||
def _strip_noncode(content: str) -> str:
|
||||
"""Blank comments and bare docstrings so IOC patterns see code only.
|
||||
|
||||
Removed regions become spaces (newlines kept) so line numbers stay exact for
|
||||
_extract_evidence. Fails open on tokenizer errors (the raw text is still
|
||||
fully scanned, so a real detection is never lost).
|
||||
"""
|
||||
try:
|
||||
toks = list(tokenize.generate_tokens(io.StringIO(content).readline))
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError, ValueError):
|
||||
return content
|
||||
|
||||
spans: list[tuple[int, int, int, int]] = [] # (srow, scol, erow, ecol)
|
||||
prev_significant = tokenize.NEWLINE # start-of-file behaves like a new line
|
||||
n = len(toks)
|
||||
for i, tok in enumerate(toks):
|
||||
ttype = tok.type
|
||||
if ttype == tokenize.COMMENT:
|
||||
spans.append((*tok.start, *tok.end))
|
||||
continue # do not advance prev_significant; comments are transparent
|
||||
if (
|
||||
ttype == tokenize.STRING
|
||||
and prev_significant in _LINE_START_TOKENS
|
||||
and not _is_fstring(tok.string) # f-strings execute; never blank them
|
||||
):
|
||||
# Bare string only if it is the whole statement: next significant
|
||||
# token must close the logical line.
|
||||
j = i + 1
|
||||
while j < n and toks[j].type in (tokenize.COMMENT, tokenize.NL):
|
||||
j += 1
|
||||
if j < n and toks[j].type == tokenize.NEWLINE:
|
||||
spans.append((*tok.start, *tok.end))
|
||||
prev_significant = ttype
|
||||
continue
|
||||
if ttype in (
|
||||
tokenize.NL,
|
||||
tokenize.NEWLINE,
|
||||
tokenize.INDENT,
|
||||
tokenize.DEDENT,
|
||||
tokenize.ENCODING,
|
||||
):
|
||||
prev_significant = ttype
|
||||
continue
|
||||
prev_significant = ttype
|
||||
|
||||
if not spans:
|
||||
return content
|
||||
|
||||
buf = content.splitlines(keepends = True)
|
||||
for srow, scol, erow, ecol in spans:
|
||||
for row in range(srow, erow + 1):
|
||||
line = buf[row - 1]
|
||||
if line.endswith("\n"):
|
||||
body, nl = line[:-1], "\n"
|
||||
elif line.endswith("\r"):
|
||||
body, nl = line[:-1], "\r"
|
||||
else:
|
||||
body, nl = line, ""
|
||||
start = scol if row == srow else 0
|
||||
end = ecol if row == erow else len(body)
|
||||
end = min(end, len(body))
|
||||
if start < end:
|
||||
body = body[:start] + (" " * (end - start)) + body[end:]
|
||||
buf[row - 1] = body + nl
|
||||
return "".join(buf)
|
||||
|
||||
|
||||
# Payload carriers that are suspicious when hidden in a blanked region (a
|
||||
# docstring/string) of a file that can dynamically execute strings.
|
||||
_HIDDEN_PAYLOAD_PATTERNS = (
|
||||
(RE_LARGE_BLOB, "large base64 blob"),
|
||||
(RE_EMBEDDED_KEYS, "embedded key material"),
|
||||
(RE_MAY12_IOC, "Shai-Hulud IOC string"),
|
||||
(RE_OBFUSCATION, "marshal/compile/obfuscation"),
|
||||
)
|
||||
|
||||
|
||||
def _hidden_payload_findings(
|
||||
original: str, stripped: str, filename: str, package: str
|
||||
) -> list[Finding]:
|
||||
"""Flag payloads that live only in the blanked (docstring/string) region of
|
||||
a file that contains exec/eval. Such a string is invisible to code-only
|
||||
scanning yet ``exec(__doc__)`` / ``exec(<str>)`` could still run it."""
|
||||
if not RE_EXEC_EVAL.search(stripped):
|
||||
return []
|
||||
out = []
|
||||
for pat, label in _HIDDEN_PAYLOAD_PATTERNS:
|
||||
if pat.search(original) and not pat.search(stripped):
|
||||
out.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
"exec/eval with payload hidden in a docstring/string",
|
||||
f"{label}: {_extract_evidence(original, pat)}",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
||||
"""Run all .py-specific checks."""
|
||||
findings = []
|
||||
# Code-only scanning: strip comments/docstrings up front so prose, doctests
|
||||
# and usage examples cannot manufacture false positives. Aligns with the
|
||||
# Hugging Face Hub model (ClamAV/picklescan: low-FP, signature/structural).
|
||||
original = content
|
||||
content = _strip_noncode(content)
|
||||
findings = _hidden_payload_findings(original, content, filename, package)
|
||||
basename = os.path.basename(filename)
|
||||
is_setup = basename in ("setup.py", "setup.cfg")
|
||||
is_init = basename == "__init__.py"
|
||||
|
|
@ -937,7 +1083,13 @@ def _extract_evidence(
|
|||
pattern: re.Pattern,
|
||||
max_matches: int = 3,
|
||||
) -> str:
|
||||
"""Pull matching lines as evidence snippets."""
|
||||
"""Pull matching lines as evidence snippets.
|
||||
|
||||
Falls back to a whole-content search when the pattern only matches across
|
||||
line boundaries (several IOC regexes use ``re.DOTALL``). Without this an
|
||||
anti-analysis / archive-staging finding could report empty evidence, making
|
||||
the baseline entry impossible to review.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
matches = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
|
|
@ -948,7 +1100,17 @@ def _extract_evidence(
|
|||
matches.append(f"L{i}: {snippet}")
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
return " | ".join(matches) if matches else ""
|
||||
if matches:
|
||||
return " | ".join(matches)
|
||||
# Multiline (DOTALL) match: report the line where the match begins.
|
||||
m = pattern.search(content)
|
||||
if m:
|
||||
line_no = content.count("\n", 0, m.start()) + 1
|
||||
snippet = lines[line_no - 1].strip() if line_no - 1 < len(lines) else ""
|
||||
if len(snippet) > 160:
|
||||
snippet = snippet[:160] + "..."
|
||||
return f"L{line_no}: {snippet}" if snippet else f"L{line_no}: <multiline match>"
|
||||
return ""
|
||||
|
||||
|
||||
# Non-Python checkers
|
||||
|
|
@ -1390,6 +1552,286 @@ _PIP_DOWNLOAD_PIN_FLAGS = [
|
|||
_RE_PKG_NAME_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
|
||||
|
||||
|
||||
# sdist fallback. `--only-binary :all:` never builds an sdist (no setup.py
|
||||
# exec), but a wheel-less project then can't be fetched at all and one such
|
||||
# package fails the whole --with-deps resolve (exit 2) -- a coverage hole. So on
|
||||
# resolve failure we drop to per-spec and fetch any sdist-only package's raw
|
||||
# tarball from the PyPI JSON API for scan_archive() to read statically: no pip,
|
||||
# no build, same no-exec guarantee. Transport failures are still exit 2; only
|
||||
# "no wheel" is downgraded to a direct fetch.
|
||||
|
||||
_SDIST_DOWNLOAD_TIMEOUT = 180
|
||||
# Never fetch an archive larger than we would be willing to scan (iter_archive_files cap).
|
||||
_MAX_SDIST_BYTES = HARD_MAX_TOTAL_BYTES
|
||||
# Direct sdist bytes only ever come from PyPI's own CDN; refuse anything else.
|
||||
_TRUSTED_PYPI_HOSTS = frozenset({"files.pythonhosted.org", "pypi.org", "pypi.python.org"})
|
||||
|
||||
|
||||
def _spec_pin_version(spec: str) -> str | None:
|
||||
"""Return the ``==X.Y.Z`` pin from a spec, or None if unpinned."""
|
||||
m = _RE_PYPI_SPEC_VERSION.search(spec)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _pypi_json(name: str) -> dict | None:
|
||||
"""Fetch a project's PyPI metadata JSON (read-only HTTPS GET, no exec); None on error."""
|
||||
url = "https://pypi.org/pypi/" + urllib.parse.quote(name, safe = "") + "/json"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers = {"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout = 30) as resp:
|
||||
if getattr(resp, "status", 200) != 200:
|
||||
return None
|
||||
data = resp.read(16 * 1024 * 1024) # metadata is small; cap regardless
|
||||
return json.loads(data.decode("utf-8", errors = "replace"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _release_files(meta: dict, version: str | None) -> list[dict]:
|
||||
"""Distribution files for a specific version, or the latest release's files."""
|
||||
if version:
|
||||
files = meta.get("releases", {}).get(version)
|
||||
if files:
|
||||
return files
|
||||
return meta.get("urls", []) or []
|
||||
|
||||
|
||||
def _release_has_wheel(meta: dict, version: str | None) -> bool:
|
||||
"""True if the (pinned or latest) release publishes any bdist_wheel."""
|
||||
return any(f.get("packagetype") == "bdist_wheel" for f in _release_files(meta, version))
|
||||
|
||||
|
||||
def _is_trusted_pypi_url(url: str) -> bool:
|
||||
"""Only download sdist bytes from PyPI's own hosts, over HTTPS."""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
return parsed.scheme == "https" and parsed.hostname in _TRUSTED_PYPI_HOSTS
|
||||
|
||||
|
||||
def _requires_dist_names(meta: dict, version: str | None) -> list[str]:
|
||||
"""Transitive dep specs (name + version specifier) from metadata, to recover
|
||||
a sdist-only package's tree. The specifier is kept so a pinned malicious
|
||||
version is fetched, not latest. Skips ``extra``-gated deps."""
|
||||
info = meta.get("info", {}) or {}
|
||||
reqs = info.get("requires_dist") or []
|
||||
specs: list[str] = []
|
||||
for r in reqs:
|
||||
if not isinstance(r, str):
|
||||
continue
|
||||
head = r
|
||||
if ";" in r:
|
||||
head, marker = r.split(";", 1)
|
||||
if "extra" in marker:
|
||||
continue # optional extra; default install would not pull it
|
||||
if not _RE_NAME.match(head.strip()):
|
||||
continue
|
||||
# "torch (>=1.10)" / "torch >=1.10" -> "torch>=1.10" (pip-friendly).
|
||||
specs.append(re.sub(r"\s+", "", head).replace("(", "").replace(")", ""))
|
||||
return specs
|
||||
|
||||
|
||||
def _download_sdist_direct(
|
||||
name: str,
|
||||
version: str | None,
|
||||
dest: str,
|
||||
*,
|
||||
meta: dict | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Fetch a project's sdist tarball directly from PyPI (no pip, no build).
|
||||
|
||||
Returns ``(filepath, error)``, one non-None. Suffix preserved for the archive
|
||||
reader; bounded by ``_MAX_SDIST_BYTES`` and restricted to PyPI's CDN.
|
||||
"""
|
||||
if meta is None:
|
||||
meta = _pypi_json(name)
|
||||
if meta is None:
|
||||
return None, f"PyPI metadata fetch failed for {name}"
|
||||
picked: tuple[str, str] | None = None
|
||||
for f in _release_files(meta, version):
|
||||
if f.get("packagetype") == "sdist" and f.get("url") and f.get("filename"):
|
||||
picked = (f["filename"], f["url"])
|
||||
break
|
||||
if picked is None:
|
||||
return None, f"no sdist published for {name} (version={version or 'latest'})"
|
||||
fname, url = picked
|
||||
if not _is_trusted_pypi_url(url):
|
||||
return None, f"refusing non-PyPI sdist URL for {name}: {url[:80]}"
|
||||
# basename + sanitize keeps the path inside dest; the char class preserves
|
||||
# the real `.tar.gz` / `.zip` suffix so the archive reader picks the format.
|
||||
safe_fname = _RE_PKG_NAME_SANITIZE.sub("_", os.path.basename(fname)) or "sdist.tar.gz"
|
||||
out = os.path.join(dest, safe_fname)
|
||||
try:
|
||||
req = urllib.request.Request(url, headers = {"Accept": "application/octet-stream"})
|
||||
with urllib.request.urlopen(req, timeout = _SDIST_DOWNLOAD_TIMEOUT) as resp:
|
||||
if getattr(resp, "status", 200) != 200:
|
||||
return None, f"sdist HTTP {getattr(resp, 'status', '?')} for {name}"
|
||||
data = resp.read(_MAX_SDIST_BYTES + 1)
|
||||
if len(data) > _MAX_SDIST_BYTES:
|
||||
return None, f"sdist for {name} exceeds {_MAX_SDIST_BYTES} byte cap"
|
||||
with open(out, "wb") as fh:
|
||||
fh.write(data)
|
||||
print(
|
||||
f" [INFO] fetched sdist directly (no build) for {name}: {safe_fname}",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return out, None
|
||||
except Exception as exc:
|
||||
return None, f"sdist download failed for {name}: {type(exc).__name__}: {str(exc)[:120]}"
|
||||
|
||||
|
||||
def _pip_download_with_deps(
|
||||
specs: list[str],
|
||||
dest: str,
|
||||
env: dict,
|
||||
*,
|
||||
timeout: int = 600,
|
||||
) -> tuple[int, str]:
|
||||
"""One `pip download --with-deps --only-binary :all:` call. Returns (rc, stderr)."""
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
*_PIP_DOWNLOAD_PIN_FLAGS,
|
||||
"--dest",
|
||||
dest,
|
||||
] + list(specs)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = timeout, env = env)
|
||||
return proc.returncode, proc.stderr or ""
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, "pip download (with deps) timed out"
|
||||
|
||||
|
||||
def _collect_flat_dir(dest: str, results: list[tuple[str, str]]) -> None:
|
||||
"""Append every archive in a flat dest dir as (pkg_name, path)."""
|
||||
for fname in sorted(os.listdir(dest)):
|
||||
fpath = os.path.join(dest, fname)
|
||||
if os.path.isfile(fpath):
|
||||
pkg_name = fname.split("-")[0].replace("_", "-").lower()
|
||||
results.append((pkg_name, fpath))
|
||||
|
||||
|
||||
def _resolve_per_spec_with_deps(
|
||||
specs: list[str], dest: str, env: dict, download_errors: list[str]
|
||||
) -> None:
|
||||
"""Fallback when the bulk --with-deps resolve fails: resolve each spec alone.
|
||||
|
||||
A still-failing spec is probed against PyPI: sdist-only -> direct fetch (deps
|
||||
recovered one level); wheel-present but tree-unresolvable -> a --no-deps fetch
|
||||
of just that package. Only a genuine fetch failure errors (caller exits 2);
|
||||
unfetchable indirect deps are warned, since the named package is still scanned.
|
||||
"""
|
||||
sdist_dep_followups: list[str] = []
|
||||
for spec in specs:
|
||||
name = _extract_pkg_name(spec)
|
||||
version = _spec_pin_version(spec)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
*_PIP_DOWNLOAD_PIN_FLAGS,
|
||||
"--dest",
|
||||
dest,
|
||||
spec,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env)
|
||||
except subprocess.TimeoutExpired:
|
||||
download_errors.append(f"per-spec --with-deps timed out for {spec}")
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
continue # archives landed in dest; collected by the caller
|
||||
meta = _pypi_json(name)
|
||||
if meta is not None and not _release_has_wheel(meta, version):
|
||||
fpath, serr = _download_sdist_direct(name, version, dest, meta = meta)
|
||||
if fpath is None:
|
||||
download_errors.append(serr or f"sdist fetch failed for {name}")
|
||||
continue
|
||||
sdist_dep_followups.extend(_requires_dist_names(meta, version))
|
||||
continue
|
||||
# Has a wheel but the full transitive tree won't co-resolve
|
||||
# (ResolutionImpossible) -- typically a package the requirement file
|
||||
# installs with --no-deps by design (e.g. descript-audio-codec, whose
|
||||
# own pins conflict). Fetch just the package itself with --no-deps so it
|
||||
# is still scanned; its conflicting deps are out of scope here (the file
|
||||
# excludes them on purpose). Only a genuine fetch failure is an error.
|
||||
nd_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
"--no-deps",
|
||||
*_PIP_DOWNLOAD_PIN_FLAGS,
|
||||
"--dest",
|
||||
dest,
|
||||
spec,
|
||||
]
|
||||
try:
|
||||
nd = subprocess.run(nd_cmd, capture_output = True, text = True, timeout = 180, env = env)
|
||||
except subprocess.TimeoutExpired:
|
||||
download_errors.append(f"per-spec --no-deps timed out for {spec}")
|
||||
continue
|
||||
if nd.returncode == 0:
|
||||
print(
|
||||
f" [INFO] {name}: full tree unresolvable; scanned the package "
|
||||
f"alone (--no-deps), recovering deps individually.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
# The --with-deps failure may have been a sdist-only TRANSITIVE dep,
|
||||
# which --no-deps skips. Recover the declared deps so that class is
|
||||
# still scanned (each is fetched as a wheel or direct sdist below).
|
||||
if meta is not None:
|
||||
sdist_dep_followups.extend(_requires_dist_names(meta, version))
|
||||
continue
|
||||
# --no-deps also failed: last-ditch sdist fetch at the pinned version.
|
||||
if meta is not None:
|
||||
fpath, _serr = _download_sdist_direct(name, version, dest, meta = meta)
|
||||
if fpath is not None:
|
||||
continue
|
||||
download_errors.append(
|
||||
f"per-spec failed for {spec} (with-deps and --no-deps): " f"{nd.stderr.strip()[:240]}"
|
||||
)
|
||||
|
||||
# Recover the transitive deps of sdist-only packages (deduped, one level).
|
||||
# `dep` carries the declared version specifier so a pinned version is fetched.
|
||||
seen: set[str] = set()
|
||||
for dep in sdist_dep_followups:
|
||||
dep_name = _extract_pkg_name(dep)
|
||||
key = _norm_pkg(dep_name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
*_PIP_DOWNLOAD_PIN_FLAGS,
|
||||
"--dest",
|
||||
dest,
|
||||
dep,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 300, env = env)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f" [WARN] dep download timed out for {dep}", file = sys.stderr)
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
continue
|
||||
dep_ver = _spec_pin_version(dep)
|
||||
meta = _pypi_json(dep_name)
|
||||
if meta is not None and not _release_has_wheel(meta, dep_ver):
|
||||
fpath, serr = _download_sdist_direct(dep_name, dep_ver, dest, meta = meta)
|
||||
if fpath is None:
|
||||
print(f" [WARN] could not fetch sdist dep {dep}: {serr}", file = sys.stderr)
|
||||
else:
|
||||
print(f" [WARN] could not resolve indirect dep {dep}; skipping", file = sys.stderr)
|
||||
|
||||
|
||||
def download_packages(
|
||||
specs: list[str],
|
||||
dest: str,
|
||||
|
|
@ -1403,49 +1845,36 @@ def download_packages(
|
|||
summaries. A non-empty ``download_errors`` MUST make the caller exit
|
||||
non-zero so a partial scan can't masquerade as "0 findings, all clean".
|
||||
|
||||
with_deps=True downloads the full transitive tree in one pip call (flat dir);
|
||||
with_deps=False (default) downloads each spec individually with --no-deps.
|
||||
with_deps=True downloads the full transitive tree (flat dir); a bulk resolve
|
||||
failure (sdist-only package or version conflict) degrades to per-spec
|
||||
resolution + direct sdist fetch rather than blanking the shard.
|
||||
with_deps=False (default) downloads each spec individually with --no-deps,
|
||||
also falling back to a direct sdist fetch when no wheel exists.
|
||||
"""
|
||||
results: list[tuple[str, str]] = []
|
||||
download_errors: list[str] = []
|
||||
env = _pip_download_env()
|
||||
|
||||
if with_deps:
|
||||
# Single pip download for all specs + transitive deps. `--only-binary
|
||||
# :all:` refuses sdists so we never execute setup.py for metadata.
|
||||
os.makedirs(dest, exist_ok = True)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"download",
|
||||
*_PIP_DOWNLOAD_PIN_FLAGS,
|
||||
"--dest",
|
||||
dest,
|
||||
] + specs
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 600, # transitive resolution is slow
|
||||
env = env,
|
||||
# Fast path: resolve + download the whole transitive tree in one call.
|
||||
# `--only-binary :all:` refuses sdists so we never build for metadata.
|
||||
rc, stderr = _pip_download_with_deps(specs, dest, env)
|
||||
if rc != 0:
|
||||
# Atomic resolve failed -- a sdist-only package, or a cross-package
|
||||
# version conflict (ResolutionImpossible). Degrade to per-spec
|
||||
# resolution so one bad spec can't blank the shard, then direct-fetch
|
||||
# any sdist-only holdouts (no build). Genuine failures still record an
|
||||
# error so the caller exits 2.
|
||||
print(
|
||||
f" [INFO] bulk --with-deps resolve failed "
|
||||
f"({stderr.strip()[:160]}); falling back to per-spec resolution "
|
||||
f"for {len(specs)} spec(s).",
|
||||
file = sys.stderr,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
msg = f"pip download (with deps) failed: " f"{proc.stderr.strip()[:500]}"
|
||||
print(f" [ERROR] {msg}", file = sys.stderr)
|
||||
download_errors.append(msg)
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = "pip download (with deps) timed out"
|
||||
print(f" [ERROR] {msg}", file = sys.stderr)
|
||||
download_errors.append(msg)
|
||||
|
||||
# Collect every archive that landed in dest
|
||||
for fname in sorted(os.listdir(dest)):
|
||||
fpath = os.path.join(dest, fname)
|
||||
if os.path.isfile(fpath):
|
||||
pkg_name = fname.split("-")[0].replace("_", "-").lower()
|
||||
results.append((pkg_name, fpath))
|
||||
_resolve_per_spec_with_deps(specs, dest, env, download_errors)
|
||||
# Collect everything that landed (bulk OR per-spec OR direct sdist).
|
||||
_collect_flat_dir(dest, results)
|
||||
else:
|
||||
for spec in specs:
|
||||
raw_name = _extract_pkg_name(spec)
|
||||
|
|
@ -1465,22 +1894,25 @@ def download_packages(
|
|||
spec,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 120,
|
||||
env = env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
msg = f"pip download failed for {spec}: " f"{proc.stderr.strip()[:500]}"
|
||||
print(f" [ERROR] {msg}", file = sys.stderr)
|
||||
download_errors.append(msg)
|
||||
continue
|
||||
proc = subprocess.run(cmd, capture_output = True, text = True, timeout = 120, env = env)
|
||||
except subprocess.TimeoutExpired:
|
||||
msg = f"pip download timed out for {spec}"
|
||||
print(f" [ERROR] {msg}", file = sys.stderr)
|
||||
download_errors.append(msg)
|
||||
download_errors.append(f"pip download timed out for {spec}")
|
||||
continue
|
||||
if proc.returncode != 0:
|
||||
# No wheel? Direct-fetch the sdist (no build) before erroring.
|
||||
name = _extract_pkg_name(spec)
|
||||
version = _spec_pin_version(spec)
|
||||
meta = _pypi_json(name)
|
||||
if meta is not None and not _release_has_wheel(meta, version):
|
||||
fpath, serr = _download_sdist_direct(name, version, pkg_dir, meta = meta)
|
||||
if fpath is not None:
|
||||
results.append((spec, fpath))
|
||||
continue
|
||||
download_errors.append(serr or f"sdist fetch failed for {name}")
|
||||
continue
|
||||
download_errors.append(
|
||||
f"pip download failed for {spec}: {proc.stderr.strip()[:300]}"
|
||||
)
|
||||
continue
|
||||
|
||||
for fname in os.listdir(pkg_dir):
|
||||
|
|
@ -1940,6 +2372,113 @@ def _find_requirements_files(root: str) -> list[str]:
|
|||
return sorted(results)
|
||||
|
||||
|
||||
# Baseline allowlist: triaged known-good CRITICAL/HIGH findings so the gate can
|
||||
# enforce without drowning in legitimate-library noise. Matched on
|
||||
# ``(package, basename(filename), check)`` -- not evidence text -- so a version
|
||||
# bump does not reopen a finding, but a *new* kind of finding in a listed file
|
||||
# is a different check and still fails. Regenerate with ``--write-baseline``.
|
||||
|
||||
_DEFAULT_BASELINE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "scan_packages_baseline.json"
|
||||
)
|
||||
|
||||
|
||||
def _norm_pkg(name: str) -> str:
|
||||
"""PEP 503-style normalization so requests/Requests/req_uests collapse."""
|
||||
return re.sub(r"[-_.]+", "-", (name or "").strip().lower())
|
||||
|
||||
|
||||
# Leading "<name>-<version>/" archive root of an sdist member, which carries the
|
||||
# version. Stripping it (but keeping the rest of the path) gives a key that is
|
||||
# stable across version bumps yet still distinguishes same-named files.
|
||||
_RE_SDIST_ROOT = re.compile(r"^[^/]+-\d[^/]*/")
|
||||
|
||||
|
||||
def _relpath_in_package(filename: str) -> str:
|
||||
"""Package-relative path: drop an sdist's version-carrying archive root.
|
||||
|
||||
Wheel members are already package-relative (``numba/cuda/utils.py``); sdist
|
||||
members sit under ``numba-0.60.0/...``, so strip that one leading segment.
|
||||
"""
|
||||
return _RE_SDIST_ROOT.sub("", filename, count = 1)
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str]:
|
||||
"""Stable allowlist key: normalized package, package-relative path, check.
|
||||
|
||||
The package-relative path (not just basename) keeps the key stable across
|
||||
version bumps while still distinguishing same-named files like ``utils.py``.
|
||||
"""
|
||||
return (_norm_pkg(f.package), _relpath_in_package(f.filename), f.check)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
"""Load an allowlist JSON into a set of match keys. Missing file -> empty."""
|
||||
try:
|
||||
with open(path, "r", encoding = "utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return set()
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str]] = set()
|
||||
for e in data.get("entries", []):
|
||||
try:
|
||||
keys.add((_norm_pkg(e["package"]), _relpath_in_package(e["file"]), e["check"]))
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
return keys
|
||||
|
||||
|
||||
def _write_baseline(path: str, findings: list[Finding]) -> None:
|
||||
"""Persist CRITICAL/HIGH findings as an allowlist for human triage."""
|
||||
entries = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for f in sorted(findings, key = lambda f: SEVERITY_ORDER.get(f.severity, 99)):
|
||||
if f.severity not in (CRITICAL, HIGH):
|
||||
continue
|
||||
key = _finding_key(f)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entries.append(
|
||||
{
|
||||
"package": f.package,
|
||||
"file": _relpath_in_package(f.filename),
|
||||
"check": f.check,
|
||||
"severity": f.severity,
|
||||
"evidence": f.evidence[:240],
|
||||
}
|
||||
)
|
||||
doc = {
|
||||
"_comment": (
|
||||
"scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding "
|
||||
"manually judged benign. Matched on (package, package-relative file, "
|
||||
"check); evidence/severity are for review only. Regenerate with "
|
||||
"--write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": 1,
|
||||
"entries": entries,
|
||||
}
|
||||
with open(path, "w", encoding = "utf-8") as fh:
|
||||
json.dump(doc, fh, indent = 2, sort_keys = False)
|
||||
fh.write("\n")
|
||||
print(f" Wrote {len(entries)} baseline entr(y/ies) to {path}")
|
||||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
return list(findings), []
|
||||
active, suppressed = [], []
|
||||
for f in findings:
|
||||
(suppressed if _finding_key(f) in baseline else active).append(f)
|
||||
return active, suppressed
|
||||
|
||||
|
||||
# Main
|
||||
|
||||
|
||||
|
|
@ -1986,6 +2525,30 @@ def main() -> int:
|
|||
metavar = "N",
|
||||
help = "Max older versions to scan when searching for safe version (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
metavar = "FILE",
|
||||
default = None,
|
||||
help = (
|
||||
"Allowlist JSON of triaged known-good findings to suppress. "
|
||||
f"Defaults to {os.path.basename(_DEFAULT_BASELINE_PATH)} next to this "
|
||||
"script if present."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-baseline",
|
||||
action = "store_true",
|
||||
help = "Ignore the auto-discovered baseline allowlist.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write-baseline",
|
||||
metavar = "FILE",
|
||||
default = None,
|
||||
help = (
|
||||
"Write the current CRITICAL/HIGH findings to FILE as an allowlist, "
|
||||
"then exit 0. Review every entry before committing it."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# --scan-dir: auto-discover requirements files
|
||||
|
|
@ -2066,11 +2629,34 @@ def main() -> int:
|
|||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors = True)
|
||||
|
||||
print_findings(all_findings)
|
||||
# Baseline allowlist: suppress triaged, known-good findings so the CI gate
|
||||
# can be enforcing without red-failing on legitimate-library noise.
|
||||
if args.no_baseline:
|
||||
baseline_path = None
|
||||
elif args.baseline:
|
||||
baseline_path = args.baseline
|
||||
elif os.path.isfile(_DEFAULT_BASELINE_PATH):
|
||||
baseline_path = _DEFAULT_BASELINE_PATH
|
||||
else:
|
||||
baseline_path = None
|
||||
baseline = _load_baseline(baseline_path) if baseline_path else set()
|
||||
|
||||
# --fix mode: auto-search for safe versions
|
||||
if args.fix and all_findings:
|
||||
critical_pkgs = {f.package for f in all_findings if f.severity == CRITICAL}
|
||||
active, suppressed = _partition_baseline(all_findings, baseline)
|
||||
|
||||
print_findings(active)
|
||||
if suppressed:
|
||||
crit_s = sum(1 for f in suppressed if f.severity == CRITICAL)
|
||||
high_s = sum(1 for f in suppressed if f.severity == HIGH)
|
||||
med_s = sum(1 for f in suppressed if f.severity == MEDIUM)
|
||||
print(
|
||||
f"\n {len(suppressed)} finding(s) suppressed by baseline "
|
||||
f"{baseline_path} "
|
||||
f"({crit_s} CRITICAL, {high_s} HIGH, {med_s} MEDIUM)."
|
||||
)
|
||||
|
||||
# --fix mode: auto-search for safe versions (only real, non-baselined ones)
|
||||
if args.fix and active:
|
||||
critical_pkgs = {f.package for f in active if f.severity == CRITICAL}
|
||||
if critical_pkgs:
|
||||
print(
|
||||
f"\n --fix: Searching for safe versions of {len(critical_pkgs)} CRITICAL package(s)..."
|
||||
|
|
@ -2079,6 +2665,7 @@ def main() -> int:
|
|||
|
||||
# Surface pip-download failures BEFORE the exit code so a partial download
|
||||
# can't masquerade as "0 findings, all clean" (silent-failure hardening 4).
|
||||
# Also keeps us from writing a baseline from an incomplete scan.
|
||||
if download_errors:
|
||||
print(
|
||||
f"\n {'=' * 72}\n"
|
||||
|
|
@ -2095,8 +2682,16 @@ def main() -> int:
|
|||
)
|
||||
return 2
|
||||
|
||||
# Exit code: 1 if any CRITICAL or HIGH
|
||||
if any(f.severity in (CRITICAL, HIGH) for f in all_findings):
|
||||
# --write-baseline: persist the full current CRITICAL/HIGH set as the new
|
||||
# allowlist (ignoring any loaded baseline), then exit 0. Only reached once
|
||||
# the scan is known complete.
|
||||
if args.write_baseline:
|
||||
_write_baseline(args.write_baseline, all_findings)
|
||||
return 0
|
||||
|
||||
# Exit code: 1 only if a NON-baselined CRITICAL or HIGH remains. This is the
|
||||
# signal CI gates on once the baseline reaches a clean run.
|
||||
if any(f.severity in (CRITICAL, HIGH) for f in active):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
|
|
|||
1329
scripts/scan_packages_baseline.json
Normal file
1329
scripts/scan_packages_baseline.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue