Skip to content

Commit d3b335e

Browse files
committed
Scanner: exclude comments from hidden-payload check, flag missing pin metadata as incomplete
Hidden network+exec detection now inspects only docstring/string spans (what exec(__doc__)/exec(<str>) can actually run), so a real exec() beside comments that mention a network and a subprocess call no longer false-positives. Missing pinned-release metadata in transitive recovery records a download_error so the --with-deps path fails closed instead of treating it as no dependencies. Adds regression tests for both.
1 parent 80379b1 commit d3b335e

2 files changed

Lines changed: 47 additions & 17 deletions

File tree

scripts/scan_packages.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,14 @@ def _is_fstring(tok_string: str) -> bool:
534534
return q > 0 and "f" in tok_string[:q].lower()
535535

536536

537-
def _strip_noncode(content: str) -> str:
537+
def _strip_noncode(content: str, blank_comments: bool = True) -> str:
538538
"""Blank comments and bare docstrings so IOC patterns see code only.
539539
540540
Removed regions become spaces (newlines kept) so line numbers stay exact for
541541
_extract_evidence. Fails open on tokenizer errors (the raw text is still
542-
fully scanned, so a real detection is never lost).
542+
fully scanned, so a real detection is never lost). ``blank_comments=False``
543+
keeps comments (only strings/docstrings blanked) to isolate the span that
544+
exec() could actually run.
543545
"""
544546
try:
545547
toks = list(tokenize.generate_tokens(io.StringIO(content).readline))
@@ -552,8 +554,9 @@ def _strip_noncode(content: str) -> str:
552554
for i, tok in enumerate(toks):
553555
ttype = tok.type
554556
if ttype == tokenize.COMMENT:
555-
spans.append((*tok.start, *tok.end))
556-
continue # do not advance prev_significant; comments are transparent
557+
if blank_comments:
558+
spans.append((*tok.start, *tok.end))
559+
continue # transparent; never advances prev_significant
557560
if (
558561
ttype == tokenize.STRING
559562
and prev_significant in _LINE_START_TOKENS
@@ -619,10 +622,11 @@ def _hidden_payload_findings(
619622
scanning yet ``exec(__doc__)`` / ``exec(<str>)`` could still run it."""
620623
if not RE_EXEC_EVAL.search(stripped):
621624
return []
622-
# The text code-only scanning removed (docstrings/comments/strings), with real
623-
# code spaced out. _strip_noncode blanks in place and preserves length, so a
624-
# char was removed exactly where stripped differs from original.
625-
removed = "".join(o if o != s else " " for o, s in zip(original, stripped))
625+
# Only docstrings/strings run via exec(__doc__)/exec(<str>); comments cannot.
626+
# Isolate that span: keep comments as real code, take what string-blanking
627+
# removed (length-preserved, so offsets stay exact for _extract_evidence).
628+
code = _strip_noncode(original, blank_comments = False)
629+
removed = "".join(o if o != s else " " for o, s in zip(original, code))
626630
out = []
627631

628632
def _hidden(pat):
@@ -1705,20 +1709,23 @@ def _requires_dist_names(meta: dict) -> list[str]:
17051709
return specs
17061710

17071711

1708-
def _requires_dist_for(name: str, version: str | None, project_meta: dict) -> list[str]:
1712+
def _requires_dist_for(
1713+
name: str, version: str | None, project_meta: dict, errors: list[str] | None = None
1714+
) -> list[str]:
17091715
"""Declared deps for the pinned version, read from that release's metadata
17101716
(its ``requires_dist`` can differ from latest). Unpinned uses the
17111717
project-level (latest) document. A pinned version whose own metadata cannot
1712-
be fetched returns [] rather than substituting latest's deps, which would
1713-
scan the wrong tree."""
1718+
be fetched returns [] (never latest's deps) and, when ``errors`` is given,
1719+
records an incomplete-scan error so a partial tree is not read as "no deps"."""
17141720
if not version:
17151721
return _requires_dist_names(project_meta)
17161722
vmeta = _pypi_json(name, version)
17171723
if vmeta is None:
1718-
print(
1719-
f" [WARN] no metadata for pinned {name}=={version}; not substituting latest deps",
1720-
file = sys.stderr,
1721-
)
1724+
msg = f"metadata fetch failed for pinned {name}=={version}; dependency scan incomplete"
1725+
if errors is None:
1726+
print(f" [WARN] {msg}", file = sys.stderr)
1727+
else:
1728+
errors.append(msg)
17221729
return []
17231730
return _requires_dist_names(vmeta)
17241731

@@ -1842,7 +1849,7 @@ def _resolve_per_spec_with_deps(
18421849
if fpath is None:
18431850
download_errors.append(serr or f"sdist fetch failed for {name}")
18441851
continue
1845-
sdist_dep_followups.extend(_requires_dist_for(name, version, meta))
1852+
sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors))
18461853
continue
18471854
# Has a wheel but the full transitive tree won't co-resolve
18481855
# (ResolutionImpossible) -- typically a package the requirement file
@@ -1876,7 +1883,7 @@ def _resolve_per_spec_with_deps(
18761883
# which --no-deps skips. Recover the declared deps so that class is
18771884
# still scanned (each is fetched as a wheel or direct sdist below).
18781885
if meta is not None:
1879-
sdist_dep_followups.extend(_requires_dist_for(name, version, meta))
1886+
sdist_dep_followups.extend(_requires_dist_for(name, version, meta, download_errors))
18801887
continue
18811888
# --no-deps also failed: last-ditch sdist fetch at the pinned version.
18821889
if meta is not None:

tests/security/test_scan_packages.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,19 @@ def test_hidden_payload_survives_visible_decoy():
447447
assert any("hidden network+exec payload" in f.check for f in findings)
448448

449449

450+
def test_comment_only_network_exec_not_flagged():
451+
# Tokens only in comments are not executable by exec(); the hidden network+exec
452+
# check inspects strings/docstrings (not comments), so this must stay clean.
453+
src = (
454+
"code = 'x = 1'\n"
455+
"exec(code)\n"
456+
"# urllib.request.urlopen('http://host/p').read()\n"
457+
"# subprocess.run(['sh', '-c', 'id'])\n"
458+
)
459+
findings = sp.check_py_file(src, "pkg/ex.py", "pkg")
460+
assert not any("hidden network+exec payload" in f.check for f in findings)
461+
462+
450463
def test_baseline_suppresses_listed_but_not_new_check(tmp_path):
451464
bl = tmp_path / "bl.json"
452465
listed = _mk(sp.CRITICAL, "fastapi", "fastapi/routing.py", "C2 polling/beaconing loop detected")
@@ -608,6 +621,16 @@ def test_requires_dist_for_uses_pinned_release(monkeypatch):
608621
assert "harmless>=1" not in specs
609622

610623

624+
def test_requires_dist_for_records_incomplete_scan_error(monkeypatch):
625+
# Missing pinned metadata must surface an incomplete-scan error, not a silent
626+
# [] that a caller cannot tell apart from a genuine no-deps release.
627+
project = _meta([], requires = ["latestdep==9.9.9"])
628+
monkeypatch.setattr(sp, "_pypi_json", lambda name, version = None: None if version else project)
629+
errors: list[str] = []
630+
assert sp._requires_dist_for("oldpkg", "1.0.0", project, errors) == []
631+
assert errors and "incomplete" in errors[0]
632+
633+
611634
def test_release_files_pinned_missing_fails_closed():
612635
# A pin absent from metadata must NOT fall back to the latest artifact.
613636
meta = _meta(

0 commit comments

Comments
 (0)