Merge remote-tracking branch 'origin/main' into merge/5945-main
# Conflicts: # pyproject.toml # scripts/uninstall.sh # studio/install_llama_prebuilt.py # studio/setup.sh
This commit is contained in:
commit
7efe4c7107
734 changed files with 123840 additions and 14780 deletions
|
|
@ -3,13 +3,14 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Enable ROCm-on-WSL for AMD Strix Halo (Radeon 8060S / gfx1151)
|
||||
# Enable ROCm-on-WSL for AMD GPUs (Strix Halo/Point APUs AND discrete Radeon RX
|
||||
# 7000/9000). Verified on gfx1151 (Radeon 8060S) and gfx1200 (Radeon RX 9060 XT).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# install.sh already routes gfx1151 to the right ROCm wheels once a ROCm runtime
|
||||
# is present; what it does NOT do is install AMD's ROCm userspace + the WSL DXG
|
||||
# bridge. This helper automates that Linux-side prerequisite on Ubuntu 24.04
|
||||
# WSL2 and is invoked by install.sh when it sees a Strix Halo APU in WSL (via
|
||||
# /dev/dxg) but no ROCm runtime yet. Fully idempotent (re-run just re-verifies).
|
||||
# install.sh routes the detected arch to the right ROCm wheels once a runtime exists;
|
||||
# what it does NOT do is install AMD's ROCm userspace + the WSL DXG bridge (librocdxg).
|
||||
# This helper does that Linux-side prerequisite on Ubuntu 24.04 WSL2, invoked by
|
||||
# install.sh when it sees an AMD GPU via /dev/dxg but no ROCm yet. Arch-agnostic: the
|
||||
# arch is auto-detected from rocminfo (override UNSLOTH_WSL_GFX=gfx1200). Idempotent.
|
||||
#
|
||||
# Manual, admin-gated Windows prerequisite: an AMD Adrenalin driver with
|
||||
# production ROCDXG/WSL support (26.2.2+). install.ps1 offers to update it. Once
|
||||
|
|
@ -34,10 +35,12 @@ set -euo pipefail
|
|||
|
||||
# ── Tunables (override via env) ──────────────────────────────────────────────
|
||||
ROCM_VER="${UNSLOTH_WSL_ROCM_VER:-7.2.1}" # ROCm release to install
|
||||
GFX="gfx1151"
|
||||
# GPU arch: empty = auto-detect from rocminfo after install (override UNSLOTH_WSL_GFX=gfx1200).
|
||||
# The ROCm + librocdxg setup is arch-agnostic; only verify + the smoke test need the arch.
|
||||
GFX="${UNSLOTH_WSL_GFX:-}"
|
||||
LIBROCDXG_REF="${UNSLOTH_LIBROCDXG_REF:-develop}" # ROCm/librocdxg git ref to build
|
||||
# AMD's gfx1151 wheel index (same one install.sh uses); only for the smoke test.
|
||||
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${GFX}/"
|
||||
# AMD's wheel index for the (optional) smoke test; resolved after arch detection.
|
||||
TORCH_INDEX=""
|
||||
# Optional torch smoke test (throwaway venv). OFF by default: install.sh installs
|
||||
# torch itself into the real venv right after, so a duplicate download is wasteful.
|
||||
SMOKE_TEST="${UNSLOTH_WSL_SMOKE_TEST:-0}"
|
||||
|
|
@ -220,12 +223,12 @@ $SUDO ldconfig
|
|||
say "Persisting ROCm-on-WSL environment"
|
||||
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
|
||||
$SUDO tee "$_envfile" >/dev/null <<EOF
|
||||
# >>> Unsloth ROCm-on-WSL (gfx1151) >>>
|
||||
# >>> Unsloth ROCm-on-WSL >>>
|
||||
export HSA_ENABLE_DXG_DETECTION=1
|
||||
export TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1
|
||||
export PATH="${ROCM_DIR}/bin:\${PATH}"
|
||||
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:\${LD_LIBRARY_PATH:-}"
|
||||
# <<< Unsloth ROCm-on-WSL (gfx1151) <<<
|
||||
# <<< Unsloth ROCm-on-WSL <<<
|
||||
EOF
|
||||
# also drop into ~/.bashrc for interactive shells
|
||||
if [ -n "${HOME:-}" ] && ! grep -q "Unsloth ROCm-on-WSL" "${HOME}/.bashrc" 2>/dev/null; then
|
||||
|
|
@ -237,32 +240,50 @@ export PATH="${ROCM_DIR}/bin:${PATH}"
|
|||
export LD_LIBRARY_PATH="${ROCM_DIR}/lib:${LD_LIBRARY_PATH:-}"
|
||||
|
||||
# ── Step 5: verify the runtime enumerates the GPU ────────────────────────────
|
||||
say "Verifying rocminfo sees ${GFX}"
|
||||
say "Verifying rocminfo enumerates the GPU over DXG"
|
||||
# Capture rocminfo into a var BEFORE grepping: piping into `grep -q` SIGPIPEs
|
||||
# rocminfo on first match, which under `set -o pipefail` turns a successful match
|
||||
# into a pipeline failure. Match the gfx1151 ISA "Name:" agent exactly (not a
|
||||
# broad gfx1[0-9]) so a generic fallback ISA or unrelated RDNA GPU can't pass.
|
||||
# into a pipeline failure.
|
||||
_rocminfo_out="$(rocminfo 2>/dev/null || true)"
|
||||
if ! printf '%s\n' "$_rocminfo_out" | grep -qE "Name:[[:space:]]*${GFX}([^0-9]|$)"; then
|
||||
# GPU agents advertise an ISA "Name: gfxNNNN". Match gfx[1-9] (excludes gfx000, the CPU
|
||||
# agent), drop the "gfx*-generic" fallback ISA, and take the first real GPU arch.
|
||||
_detected_gfx="$(printf '%s\n' "$_rocminfo_out" | grep -E 'Name:[[:space:]]*gfx[1-9]' | grep -v 'generic' | grep -oE 'gfx[1-9][0-9a-z]*' | head -1 || true)"
|
||||
if [ -z "$_detected_gfx" ]; then
|
||||
printf '%s\n' "$_rocminfo_out" | head -25 >&2 || true
|
||||
die "rocminfo did not enumerate a ${GFX} GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
|
||||
die "rocminfo did not enumerate any GPU agent. Most common cause: the Windows AMD driver predates production ROCDXG -- update Adrenalin (install.ps1 offers this), reboot, and re-run."
|
||||
fi
|
||||
# Honour a caller-pinned arch (sanity-check via a consuming grep, not grep -q: under
|
||||
# pipefail -q would SIGPIPE printf on large output and misreport the arch); else adopt.
|
||||
if [ -n "$GFX" ] && ! printf '%s\n' "$_rocminfo_out" | grep -E "Name:[[:space:]]*${GFX}([^0-9]|$)" >/dev/null; then
|
||||
die "rocminfo enumerated '${_detected_gfx}' but not the requested UNSLOTH_WSL_GFX='${GFX}'."
|
||||
fi
|
||||
GFX="${GFX:-$_detected_gfx}"
|
||||
# Display-only summary: best-effort (|| true) so head's early pipe-close under
|
||||
# `set -o pipefail` can't fail the bootstrap after verification already passed.
|
||||
printf '%s\n' "$_rocminfo_out" | grep -E 'Marketing Name|Device Type|Compute Unit' | grep -iE "Radeon|GPU|Compute" | head -3 || true
|
||||
note "ROCm-on-WSL runtime is live for ${GFX}."
|
||||
|
||||
# ── Step 6 (optional): torch smoke test from the gfx1151 index ───────────────
|
||||
# ── Step 6 (optional): torch smoke test from AMD's per-arch wheel index ───────
|
||||
if [ "$SMOKE_TEST" = "1" ]; then
|
||||
say "Smoke-testing PyTorch on ${GFX} (throwaway venv)"
|
||||
# Map the detected arch to AMD's repo.amd.com wheel family index.
|
||||
case "$GFX" in
|
||||
gfx1200|gfx1201) _fam="gfx120X-all" ;;
|
||||
gfx1100|gfx1101|gfx1102|gfx1103) _fam="gfx110X-all" ;;
|
||||
*) _fam="$GFX" ;; # gfx1150/gfx1151/gfx90a: own index
|
||||
esac
|
||||
TORCH_INDEX="${UNSLOTH_AMD_ROCM_MIRROR:-https://repo.amd.com/rocm/whl}/${_fam}/"
|
||||
_venv="${HOME}/.unsloth/rocm-smoketest"
|
||||
rm -rf "$_venv"; python3 -m venv "$_venv"
|
||||
"$_venv/bin/pip" install --quiet --upgrade pip
|
||||
# gfx1151 index is primary (torch + triton); PyPI only an extra for pure-py
|
||||
# AMD arch index is primary (torch + triton); PyPI only an extra for pure-py
|
||||
# deps. The constraint keeps pip on the ROCm wheel, not a newer PyPI CUDA torch.
|
||||
"$_venv/bin/pip" install --index-url "$TORCH_INDEX" \
|
||||
--extra-index-url https://pypi.org/simple "$TORCH_CONSTRAINT" || \
|
||||
die "torch install from ${TORCH_INDEX} failed."
|
||||
# WSL: torch's bundled ROCr must load the DXG bridge -- drop librocdxg into torch/lib.
|
||||
_tlib="$("$_venv/bin/python" -c 'import torch,os;print(os.path.join(os.path.dirname(torch.__file__),"lib"))' 2>/dev/null || true)"
|
||||
[ -d "$_tlib" ] && cp -f "${ROCM_DIR}"/lib/librocdxg.so* "$_tlib"/ 2>/dev/null || true
|
||||
"$_venv/bin/python" - <<'PY'
|
||||
import torch
|
||||
ok = torch.cuda.is_available()
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import atexit
|
||||
import base64 as _b64 # imported only so the IOC string-scan can detect it
|
||||
import bisect
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -897,20 +899,364 @@ def safe_extract(
|
|||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# How far back to look for an enclosing bracket opener. Symmetric with the
|
||||
# forward cap so a host that sits deep inside a large options object (its opening
|
||||
# `{` many properties above) still binds the whole object, not just its own line;
|
||||
# a too-far start only over-binds (more context, still fail-closed), never less.
|
||||
_MAX_CONT_LINES = 200
|
||||
# Hard cap on how far forward a bracket group is followed to its close, measured
|
||||
# from the matched line so the tail after the match is always reachable even when
|
||||
# the opener was found near the backward limit (digest input only, never
|
||||
# displayed); a realistic config object closes well within it.
|
||||
_MAX_GROUP_LINES = 200
|
||||
|
||||
# JS string literal (single / double / template), blanked before counting
|
||||
# brackets so a bracket inside a string is not mistaken for code.
|
||||
_RE_JS_STR = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
|
||||
|
||||
|
||||
_RE_BRACKETS = re.compile(r"[()\[\]{}]")
|
||||
_OPENERS = frozenset("([{")
|
||||
|
||||
|
||||
def _bracket_lr(line: str) -> tuple[int, int]:
|
||||
"""Order-aware bracket reduction of one already-string-blanked line: ``(L, R)``
|
||||
where ``L`` is the count of closers with no opener earlier on the line (they
|
||||
need an opener to the LEFT / on a prior line) and ``R`` is the count of openers
|
||||
with no closer later on the line (they need a closer to the RIGHT / on a later
|
||||
line). A plain net count (opens minus closes) collapses order and so masks a
|
||||
trailing opener that follows leading closers on the same line, e.g.
|
||||
``}); const opts = {`` nets -1 and hides the ``{`` that opens the host-config
|
||||
object; tracking the running minimum keeps that opener visible so the group
|
||||
binds the path/headers that follow. Only bracket characters are walked (pulled
|
||||
out with one C-level regex pass) so a long minified line stays cheap."""
|
||||
depth = 0
|
||||
low = 0
|
||||
for ch in _RE_BRACKETS.findall(line):
|
||||
if ch in _OPENERS:
|
||||
depth += 1
|
||||
else:
|
||||
depth -= 1
|
||||
if depth < low:
|
||||
low = depth
|
||||
return -low, depth - low
|
||||
|
||||
|
||||
def _find_unescaped(line: str, quote: str, start: int) -> int:
|
||||
"""Index of the next ``quote`` at or after ``start`` not escaped by a backslash,
|
||||
or -1. Skips ``\\x`` pairs so an escaped quote inside the string is ignored."""
|
||||
i, n = start, len(line)
|
||||
while i < n:
|
||||
if line[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if line[i] == quote:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
# A `/` is a regex literal (not division) when the previous significant character
|
||||
# is none (start) or one of these expression-position chars. Used only by the
|
||||
# multi-line blanked view, and the span is unioned with the single-line view, so
|
||||
# an over- or under-detection only ever grows the bound span (never shrinks it).
|
||||
_JS_REGEX_PRECEDERS = frozenset("([{,;:?=&|!+-*/%^~<>")
|
||||
|
||||
|
||||
def _blank_js_strings(lines: list[str]) -> list[str]:
|
||||
"""Replace string contents (single, double, multi-line backtick template
|
||||
literals) AND regex literal bodies with spaces across ``lines``, keeping the
|
||||
line count and every bracket OUTSIDE a string/regex intact, so bracket counting
|
||||
never miscounts a ``)`` that lives inside a string -- including a template
|
||||
literal spanning several lines or a ``/)/`` regex -- which a per-line regex
|
||||
cannot blank. Escapes are honoured."""
|
||||
out: list[str] = []
|
||||
in_back = False # inside a multi-line `template` literal
|
||||
prev_sig = "" # last significant non-space char (for regex-vs-division)
|
||||
for line in lines:
|
||||
buf: list[str] = []
|
||||
i, n = 0, len(line)
|
||||
while i < n:
|
||||
if in_back:
|
||||
end = _find_unescaped(line, "`", i)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 1))
|
||||
i = end + 1
|
||||
in_back = False
|
||||
prev_sig = "`"
|
||||
continue
|
||||
ch = line[i]
|
||||
if ch in " \t":
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch in "'\"`":
|
||||
end = _find_unescaped(line, ch, i + 1)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
if ch == "`": # opens a template literal that runs past this line
|
||||
in_back = True
|
||||
else:
|
||||
buf.append(" " * (end - i + 1))
|
||||
i = end + 1
|
||||
prev_sig = "v" # a string is a value: a following `/` is division
|
||||
continue
|
||||
if ch == "/" and (prev_sig == "" or prev_sig in _JS_REGEX_PRECEDERS):
|
||||
# Regex literal: blank to the closing unescaped `/` outside a `[...]`
|
||||
# char class. A regex never spans lines, so no close on the line
|
||||
# means this `/` is really division.
|
||||
j, in_class, closed = i + 1, False, False
|
||||
while j < n:
|
||||
c = line[j]
|
||||
if c == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if c == "[":
|
||||
in_class = True
|
||||
elif c == "]":
|
||||
in_class = False
|
||||
elif c == "/" and not in_class:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
if closed:
|
||||
buf.append(" " * (j - i))
|
||||
i = j
|
||||
prev_sig = "v" # a regex is a value
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
prev_sig = "/"
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
prev_sig = ch
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
def _index_text(text: str) -> tuple[list[str], list[str], list[str], list[int]]:
|
||||
"""Precompute once per evidence call: raw lines for display, two string-blanked
|
||||
views for bracket counting (single-line via regex = legacy, and multi-line
|
||||
aware so a template literal spanning lines is blanked), and newline offsets for
|
||||
O(log n) offset-to-line mapping. Avoids re-splitting and re-counting the whole
|
||||
file on every single match (which was O(matches x file size))."""
|
||||
lines = text.split("\n")
|
||||
sl_blanked = [_RE_JS_STR.sub("", ln) for ln in lines]
|
||||
ml_blanked = _blank_js_strings(lines)
|
||||
nl = [p for p, ch in enumerate(text) if ch == "\n"]
|
||||
return lines, sl_blanked, ml_blanked, nl
|
||||
|
||||
|
||||
# Cap on formatted matches in one evidence string; beyond it the remaining match
|
||||
# texts are folded into a single digest so a huge/minified file cannot build a
|
||||
# multi-megabyte evidence blob while an added/removed match past the cap still
|
||||
# changes the key.
|
||||
_MAX_EVIDENCE_MATCHES = 64
|
||||
|
||||
|
||||
def _scan_group(blanked: list[str], idx: int) -> tuple[int, int]:
|
||||
"""(start, end) line indices of the bracket group enclosing line ``idx`` in one
|
||||
blanked view: scan back to the still-open opener, then forward to its close."""
|
||||
# Backward: find the line that opens a bracket still unclosed at the match,
|
||||
# so a match inside a multi-line object starts from the object opener. Each line
|
||||
# is reduced to (L, R) and applied in order: first the L closers consume open
|
||||
# brackets from the running context (a stray closer whose opener is outside the
|
||||
# window only clamps depth at 0, it never goes negative), then the R openers
|
||||
# add to it. Tracking order this way (rather than a single net per line) keeps a
|
||||
# trailing opener visible even when leading closers on the same line net it to
|
||||
# <= 0, e.g. `}); const opts = {`, which a net count would drop -- letting a
|
||||
# changed path/headers after such a line ride the unchanged-hostname key.
|
||||
start = idx
|
||||
depth = 0
|
||||
for j in range(max(0, idx - _MAX_CONT_LINES), idx):
|
||||
left, right = _bracket_lr(blanked[j])
|
||||
if left >= depth:
|
||||
depth = 0 # everything opened so far in the window has closed
|
||||
start = idx
|
||||
else:
|
||||
depth -= left
|
||||
if right > 0:
|
||||
if depth == 0:
|
||||
start = j # outermost still-open opener begins here
|
||||
depth += right
|
||||
|
||||
# Forward: extend until the group opened at `start` closes past the match. The
|
||||
# same order-aware reduction is used (clamping leading closers at 0) so the
|
||||
# foreign `})` on the opener line does not drive the count negative and stop the
|
||||
# scan before the real close. The cap is measured from the match (`idx`), not
|
||||
# from `start`, so an opener found near the backward limit does not eat the
|
||||
# whole forward budget and drop the path/headers/body that follow the match.
|
||||
depth = 0
|
||||
end = start
|
||||
for j in range(start, min(len(blanked), idx + _MAX_GROUP_LINES)):
|
||||
left, right = _bracket_lr(blanked[j])
|
||||
depth = max(0, depth - left) + right
|
||||
end = j
|
||||
if j >= idx and depth <= 0:
|
||||
break
|
||||
return start, end
|
||||
|
||||
|
||||
def _canon_preserve_strings(text: str) -> str:
|
||||
"""Whitespace canon that collapses runs OUTSIDE string literals to a single
|
||||
space (so a reindent or spacing change between tokens stays stable) while
|
||||
preserving whitespace INSIDE single/double/backtick string literals (so a
|
||||
changed payload body, e.g. ``'a b'`` -> ``'a b'``, reopens). A plain
|
||||
``" ".join(text.split())`` erases both, suppressing an intra-literal payload
|
||||
edit along with harmless indentation. Leading/trailing outside whitespace is
|
||||
dropped; escapes inside strings are honoured. Used for the evidence hash and
|
||||
the logical-line digests so the two stay consistent."""
|
||||
out: list[str] = []
|
||||
i, n = 0, len(text)
|
||||
quote: str | None = None
|
||||
pending_space = False
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if quote is not None:
|
||||
out.append(ch)
|
||||
if ch == "\\" and i + 1 < n:
|
||||
out.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == quote:
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
if ch.isspace():
|
||||
pending_space = True
|
||||
i += 1
|
||||
continue
|
||||
if pending_space and out:
|
||||
out.append(" ")
|
||||
pending_space = False
|
||||
out.append(ch)
|
||||
if ch in "'\"`":
|
||||
quote = ch
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _logical_line_text(
|
||||
lines: list[str], sl_blanked: list[str], ml_blanked: list[str], idx: int
|
||||
) -> str:
|
||||
"""The matched line plus the bracket group it belongs to (the enclosing
|
||||
multi-line object/call, so a changed ``path``/``headers``/body on another line
|
||||
binds). Returns the UNION of the groups found in the single-line-blanked view
|
||||
(legacy: a payload embedded inside a template still counts so its brackets bind
|
||||
the call) and the multi-line-blanked view (a bracket inside a template literal
|
||||
spanning lines no longer closes the group early). Unioning never shrinks the
|
||||
span below either view, so neither blanking strategy can drop a line a
|
||||
malicious change relies on."""
|
||||
s1, e1 = _scan_group(sl_blanked, idx)
|
||||
s2, e2 = _scan_group(ml_blanked, idx)
|
||||
start, end = min(s1, s2), max(e1, e2)
|
||||
return " ".join(lines[start : end + 1])
|
||||
|
||||
|
||||
def _format_match(
|
||||
text: str,
|
||||
lines: list[str],
|
||||
sl_blanked: list[str],
|
||||
ml_blanked: list[str],
|
||||
nl: list[int],
|
||||
m: re.Match,
|
||||
max_chars: int,
|
||||
) -> str:
|
||||
# The shown snippet is a small window around the match; append a digest of the
|
||||
# full LOGICAL line (the matched line plus its bracket-continuation lines)
|
||||
# whenever the snippet does not already show all of it, so a changed payload
|
||||
# tail, a truncated body, or a multi-line option/header reopens. Offsets are
|
||||
# mapped to line numbers via bisect over precomputed newline positions, so this
|
||||
# is O(log n) instead of rescanning the file prefix for every match.
|
||||
idx = bisect.bisect_left(nl, m.start()) # 0-based line index of the match
|
||||
line_start = nl[idx - 1] + 1 if idx > 0 else 0
|
||||
ke = bisect.bisect_left(nl, m.end())
|
||||
line_end = nl[ke] if ke < len(nl) else len(text)
|
||||
full_logical = _logical_line_text(lines, sl_blanked, ml_blanked, idx)
|
||||
start = max(line_start, m.start() - 30)
|
||||
end = min(line_end, m.end() + 30)
|
||||
snippet = text[start:end].replace("\n", " ")
|
||||
if len(snippet) > max_chars:
|
||||
snippet = snippet[:max_chars] + "..."
|
||||
if snippet != full_logical:
|
||||
# Normalize before digesting, matching _evidence_hash, so a formatter-only
|
||||
# reindent of the bound continuation lines does not reopen -- but preserve
|
||||
# whitespace inside string literals so a changed request/payload body does.
|
||||
canon = _canon_preserve_strings(full_logical)
|
||||
digest = hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest()
|
||||
snippet = f"{snippet} sha256:{digest}"
|
||||
return snippet
|
||||
|
||||
|
||||
def _stream_overflow_digest(
|
||||
matches, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int]
|
||||
) -> tuple[int, str]:
|
||||
"""A single digest binding the LOGICAL line (the bound bracket-group context,
|
||||
not just the regex match text) of every overflow match in the iterable, plus
|
||||
the count of matches folded. Streams the matches (any iterable of re.Match) so a
|
||||
huge overflow never materializes a list. Whitespace-normalized to match
|
||||
_evidence_hash so a reindent does not reopen."""
|
||||
h = hashlib.sha256()
|
||||
count = 0
|
||||
for m in matches:
|
||||
_fold_overflow_match(h, m, lines, sl_blanked, ml_blanked, nl)
|
||||
count += 1
|
||||
return count, h.hexdigest()
|
||||
|
||||
|
||||
def _fold_overflow_match(
|
||||
h, m: re.Match, lines: list[str], sl_blanked: list[str], ml_blanked: list[str], nl: list[int]
|
||||
) -> None:
|
||||
"""Fold one overflow match's whitespace-normalized logical-line context into the
|
||||
running hash ``h``. Shared by _stream_overflow_digest and the inline overflow
|
||||
fold in _outbound_host_evidence so both produce the identical digest."""
|
||||
idx = bisect.bisect_left(nl, m.start())
|
||||
ll = _logical_line_text(lines, sl_blanked, ml_blanked, idx)
|
||||
h.update(b"\x00")
|
||||
h.update(_canon_preserve_strings(ll).encode("utf-8", "replace"))
|
||||
|
||||
|
||||
def _evidence(
|
||||
text: str,
|
||||
pat: re.Pattern,
|
||||
max_chars: int = 200,
|
||||
) -> str:
|
||||
m = pat.search(text)
|
||||
if not m:
|
||||
# Record every match (not a truncated sample) so an extra match appended to an
|
||||
# already-flagged file changes the evidence instead of riding the first few.
|
||||
# Past _MAX_EVIDENCE_MATCHES the remaining matches are folded into one digest
|
||||
# (binding their logical-line context) so the evidence string stays bounded
|
||||
# while a changed payload past the cap still reopens. The matches are streamed
|
||||
# from finditer rather than materialized into a list: a generated file can
|
||||
# repeat a cheap signal (e.g. NPM_TOKEN) millions of times, and holding a
|
||||
# re.Match per occurrence before applying the cap would stall or OOM the scan.
|
||||
it = pat.finditer(text)
|
||||
shown_matches = list(itertools.islice(it, _MAX_EVIDENCE_MATCHES))
|
||||
if not shown_matches:
|
||||
return ""
|
||||
start = max(0, m.start() - 30)
|
||||
end = min(len(text), m.end() + 30)
|
||||
snippet = text[start:end].replace("\n", " ")
|
||||
if len(snippet) > max_chars:
|
||||
snippet = snippet[:max_chars] + "..."
|
||||
return snippet
|
||||
lines, sl_blanked, ml_blanked, nl = _index_text(text)
|
||||
shown = [
|
||||
_format_match(text, lines, sl_blanked, ml_blanked, nl, m, max_chars) for m in shown_matches
|
||||
]
|
||||
# Fold the rest (past the cap) into one digest as they arrive, never building a
|
||||
# second list. Byte-identical to digesting matches[_MAX_EVIDENCE_MATCHES:].
|
||||
overflow_count, digest = _stream_overflow_digest(it, lines, sl_blanked, ml_blanked, nl)
|
||||
if overflow_count:
|
||||
shown.append(f"(+{overflow_count} more) sha256:{digest}")
|
||||
return " | ".join(shown)
|
||||
|
||||
|
||||
def _ioc_evidence(text: str, needle: str) -> str:
|
||||
"""Matched-line context (with bracket-group continuation) for a literal IOC
|
||||
needle, so a changed adjacent fetch/exfil body reopens the key instead of
|
||||
riding the bare constant. Falls back to the needle itself if, defensively,
|
||||
nothing matches (the caller only reaches here when ``needle in text``)."""
|
||||
return _evidence(text, re.compile(re.escape(needle))) or needle
|
||||
|
||||
|
||||
LIFECYCLE_HOOKS = ("preinstall", "install", "postinstall", "prepare")
|
||||
|
|
@ -1129,6 +1475,18 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
body = scripts.get(hook)
|
||||
if not isinstance(body, str):
|
||||
continue
|
||||
# Pin the whole lifecycle body via one digest shared by every lifecycle
|
||||
# finding below: a script that keeps the matched signal but changes
|
||||
# another line (e.g. swapping `echo safe` for `curl -d "$NPM_TOKEN"
|
||||
# https://evil`) must reopen. The stored evidence is a bounded matched
|
||||
# snippet plus this digest, never the entire body, so `--write-baseline`
|
||||
# on a package with a multi-MiB install script does not bloat the baseline
|
||||
# JSON while the digest still binds the full body. Normalized to match
|
||||
# _evidence_hash so a reindent alone does not reopen, while whitespace
|
||||
# inside quoted strings is preserved so a changed quoted payload does.
|
||||
body_digest = hashlib.sha256(
|
||||
_canon_preserve_strings(body).encode("utf-8", "replace")
|
||||
).hexdigest()
|
||||
if _LIFECYCLE_FETCH_EXEC.search(body):
|
||||
findings.append(
|
||||
Finding(
|
||||
|
|
@ -1136,7 +1494,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"lifecycle-fetch-exec ({hook})",
|
||||
evidence = body,
|
||||
evidence = f"{_evidence(body, _LIFECYCLE_FETCH_EXEC)} body-sha256:{body_digest}",
|
||||
detail = (
|
||||
f"`scripts.{hook}` fetches an external "
|
||||
"resource and pipes/chains it to an "
|
||||
|
|
@ -1155,7 +1513,10 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"cred-path-in-lifecycle ({hook})",
|
||||
evidence = body,
|
||||
evidence = (
|
||||
f"{_evidence(body, re.compile(re.escape(path_substr)))} "
|
||||
f"body-sha256:{body_digest}"
|
||||
),
|
||||
detail = (
|
||||
f"`scripts.{hook}` references {why} "
|
||||
f"({path_substr!r}); install-time access "
|
||||
|
|
@ -1171,7 +1532,7 @@ def scan_package_json(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = f"cred-env-in-lifecycle ({hook})",
|
||||
evidence = _evidence(body, _JS_ENV_TOKEN),
|
||||
evidence = f"{_evidence(body, _JS_ENV_TOKEN)} body-sha256:{body_digest}",
|
||||
detail = (
|
||||
f"`scripts.{hook}` references a credential "
|
||||
"env var (GITHUB_TOKEN / NPM_TOKEN / AWS_* "
|
||||
|
|
@ -1237,6 +1598,60 @@ def _host_in_outbound_context(text: str, host: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _outbound_host_evidence(text: str, host: str) -> str:
|
||||
"""Evidence capturing the host WITH its outbound context (URL path, fetch
|
||||
call, host config), so a changed path/headers/body reopens the key instead
|
||||
of riding the bare host literal. Falls back to the host if none matches."""
|
||||
host_re = re.escape(host)
|
||||
patterns = (
|
||||
re.compile(rf"(?:https?:)?//{host_re}(?:[:/\"'?#][^\n]*)?", re.IGNORECASE),
|
||||
re.compile(
|
||||
rf"(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}{host_re}[^\n]{{0,200}}"
|
||||
rf"|{host_re}[^\n]{{0,200}}(?:{_FETCH_VERBS_PAT})[^\n]{{0,200}}",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# Host-config form: capture the whole line (path/headers/body), so a
|
||||
# changed outbound payload on the same hostname line reopens the key.
|
||||
re.compile(rf"[^\n]*(?:host|hostname)\s*:\s*['\"`]{host_re}['\"`][^\n]*", re.IGNORECASE),
|
||||
)
|
||||
# Record EVERY outbound context for the host, not just the first form that
|
||||
# matches: a file that already has a baselined URL for the host and later adds
|
||||
# a separate host-config request (or a second URL) must change the evidence so
|
||||
# the new payload cannot inherit the old key. Forms are claimed in order, and a
|
||||
# region already claimed by an earlier form is skipped, so the common
|
||||
# single-context case keeps its existing snippet. Each form is capped at
|
||||
# _MAX_EVIDENCE_MATCHES matches so a host repeated thousands of times in a
|
||||
# minified file cannot make the overlap check quadratic; once chosen is full
|
||||
# the rest are folded into a digest AS THEY ARRIVE (never accumulated into a
|
||||
# list, so a host repeated millions of times cannot OOM the scan) and an added
|
||||
# context still reopens.
|
||||
lines, sl_blanked, ml_blanked, nl = _index_text(text)
|
||||
claimed: list[tuple[int, int]] = []
|
||||
chosen: list[re.Match] = []
|
||||
overflow_count = 0
|
||||
overflow_hash = hashlib.sha256()
|
||||
for pat in patterns:
|
||||
for m in pat.finditer(text):
|
||||
if len(chosen) < _MAX_EVIDENCE_MATCHES:
|
||||
# Overlap check runs only while filling the display list, so
|
||||
# `claimed` is bounded by the cap and this stays O(cap) per match
|
||||
# (not quadratic), while every later match is still counted below.
|
||||
if any(m.start() < e and s < m.end() for s, e in claimed):
|
||||
continue
|
||||
claimed.append((m.start(), m.end()))
|
||||
chosen.append(m)
|
||||
else:
|
||||
_fold_overflow_match(overflow_hash, m, lines, sl_blanked, ml_blanked, nl)
|
||||
overflow_count += 1
|
||||
if not chosen:
|
||||
return host
|
||||
chosen.sort(key = lambda m: m.start())
|
||||
shown = [_format_match(text, lines, sl_blanked, ml_blanked, nl, m, 1000) for m in chosen]
|
||||
if overflow_count:
|
||||
shown.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}")
|
||||
return " | ".join(shown)
|
||||
|
||||
|
||||
def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
|
||||
|
|
@ -1248,7 +1663,10 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
if rel.lower().endswith(_JS_FAMILY_SUFFIXES):
|
||||
text = _strip_js_noncode(text)
|
||||
|
||||
# IOC substrings (literal, case-sensitive).
|
||||
# IOC substrings (literal, case-sensitive). Evidence is the matched-line
|
||||
# context (with its bracket-group continuation), not the bare needle: an IOC
|
||||
# host/hash left in place while the adjacent fetch/exfil body changes must
|
||||
# reopen the key instead of riding the constant.
|
||||
for needle, (sev, why) in KNOWN_IOC_STRINGS.items():
|
||||
if needle in text:
|
||||
findings.append(
|
||||
|
|
@ -1257,12 +1675,14 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "known-ioc-string",
|
||||
evidence = needle,
|
||||
evidence = _ioc_evidence(text, needle),
|
||||
detail = f"{why}: {needle!r}",
|
||||
)
|
||||
)
|
||||
|
||||
# Cred surfaces, tier 1: hosts with no legit use; bare substring.
|
||||
# Cred surfaces, tier 1: hosts with no legit use. Bind the outbound context
|
||||
# (path/headers/body) when present so a changed exfil payload on the same call
|
||||
# reopens; falls back to the bare host when it is not in an outbound call.
|
||||
for needle, why in CRED_HOST_ALWAYS_BAD:
|
||||
if needle in text:
|
||||
findings.append(
|
||||
|
|
@ -1271,7 +1691,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "cred-surface-host (always-bad)",
|
||||
evidence = needle,
|
||||
evidence = _outbound_host_evidence(text, needle),
|
||||
detail = (
|
||||
f"references {why} ({needle!r}); no legitimate "
|
||||
"frontend use of this surface"
|
||||
|
|
@ -1289,7 +1709,7 @@ def scan_text_blob(pkg: PackageEntry, rel: str, text: str) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "cred-surface-host (outbound)",
|
||||
evidence = needle,
|
||||
evidence = _outbound_host_evidence(text, needle),
|
||||
detail = (
|
||||
f"references {why} ({needle!r}) in an outbound "
|
||||
"call / URL / host config; a defensive blocklist "
|
||||
|
|
@ -1393,7 +1813,7 @@ def scan_extracted_tree(pkg: PackageEntry, root: Path) -> list[Finding]:
|
|||
package = pkg.display,
|
||||
filename = rel,
|
||||
pattern = "known-ioc-string",
|
||||
evidence = needle,
|
||||
evidence = _ioc_evidence(text, needle),
|
||||
detail = f"{why}: {needle!r}",
|
||||
)
|
||||
)
|
||||
|
|
@ -1453,11 +1873,11 @@ def scan_one(pkg: PackageEntry, workspace: Path) -> tuple[list[Finding], str | N
|
|||
|
||||
_DEFAULT_BASELINE_PATH = str(Path(__file__).resolve().parent / "scan_npm_packages_baseline.json")
|
||||
|
||||
# Bumped when the entry-key semantics change. v2 keys on the package-relative
|
||||
# path; v1 stored only a basename, so a v1 entry could suppress a same-named file
|
||||
# in a different directory. A pre-v2 baseline with entries is ignored (fail
|
||||
# closed) rather than mis-applied.
|
||||
_BASELINE_SCHEMA_VERSION = 2
|
||||
# Bumped when the entry-key semantics change. v3 adds an evidence hash so a new
|
||||
# payload under an already-listed package/path/pattern is not auto-suppressed; v2
|
||||
# keyed on the package-relative path; v1 stored only a basename. A pre-v3 baseline
|
||||
# with entries is ignored (fail closed) rather than mis-applied.
|
||||
_BASELINE_SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
def _norm_pkg_name(display: str) -> str:
|
||||
|
|
@ -1486,12 +1906,28 @@ def _relpath_in_package(filename: str) -> str:
|
|||
return f[len(_NPM_TARBALL_ROOT) :] if f.startswith(_NPM_TARBALL_ROOT) else f
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str]:
|
||||
"""Stable allowlist key: normalized package, package-relative path, pattern."""
|
||||
return (_norm_pkg_name(f.package), _relpath_in_package(f.filename), f.pattern)
|
||||
def _evidence_hash(evidence: str) -> str:
|
||||
"""Stable digest of the matched evidence. The npm snippet carries no line
|
||||
markers, so it is already version-stable; whitespace outside string literals is
|
||||
collapsed (reindent-stable) while whitespace inside literals is preserved, so a
|
||||
changed payload body reopens but a formatter reindent does not."""
|
||||
canon = _canon_preserve_strings(evidence or "")
|
||||
return hashlib.sha256(canon.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str, str]:
|
||||
"""Allowlist key: normalized package, package-relative path, pattern, and a
|
||||
hash of the matched evidence -- so changed flagged code under an already-listed
|
||||
package/path/pattern reopens instead of riding the reviewed entry."""
|
||||
return (
|
||||
_norm_pkg_name(f.package),
|
||||
_relpath_in_package(f.filename),
|
||||
f.pattern,
|
||||
_evidence_hash(f.evidence or f.detail),
|
||||
)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, 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:
|
||||
|
|
@ -1501,27 +1937,55 @@ def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
|||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f" [WARN] could not read baseline {path}: {exc}", file = sys.stderr)
|
||||
return set()
|
||||
if not isinstance(data, dict):
|
||||
print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr)
|
||||
return set()
|
||||
entries = data.get("entries", [])
|
||||
if entries and data.get("version") != _BASELINE_SCHEMA_VERSION:
|
||||
if not isinstance(entries, list):
|
||||
print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr)
|
||||
return set()
|
||||
# v2 shares v3's package-relative keying, so its entries migrate by recomputing
|
||||
# the evidence hash from their stored evidence; only pre-v2 (basename) is rejected.
|
||||
if entries and data.get("version") not in (_BASELINE_SCHEMA_VERSION, 2):
|
||||
print(
|
||||
f" [WARN] baseline schema v{data.get('version')} predates package-relative "
|
||||
f"keys; ignoring {len(entries)} entr(y/ies). Regenerate with --write-baseline.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str]] = set()
|
||||
keys: set[tuple[str, str, str, str]] = set()
|
||||
legacy = 0
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
try:
|
||||
keys.add((_norm_pkg_name(e["package"]), _relpath_in_package(e["file"]), e["pattern"]))
|
||||
evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "")
|
||||
if not e.get("evidence_hash"):
|
||||
legacy += 1
|
||||
keys.add(
|
||||
(
|
||||
_norm_pkg_name(e["package"]),
|
||||
_relpath_in_package(e["file"]),
|
||||
e["pattern"],
|
||||
evidence_hash,
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
if legacy:
|
||||
print(
|
||||
f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may "
|
||||
f"not suppress until regenerated with --write-baseline (findings reopen "
|
||||
f"rather than risk hiding changed code under a coarse key)",
|
||||
file = sys.stderr,
|
||||
)
|
||||
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()
|
||||
seen: set[tuple[str, 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
|
||||
|
|
@ -1529,21 +1993,24 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) ->
|
|||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
evidence = f.evidence or f.detail
|
||||
entries.append(
|
||||
{
|
||||
"package": _norm_pkg_name(f.package),
|
||||
"file": _relpath_in_package(f.filename),
|
||||
"pattern": f.pattern,
|
||||
"severity": f.severity,
|
||||
"evidence": (f.evidence or f.detail)[:240],
|
||||
"evidence": evidence,
|
||||
"evidence_hash": _evidence_hash(evidence),
|
||||
}
|
||||
)
|
||||
doc = {
|
||||
"_comment": (
|
||||
"scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL "
|
||||
"finding manually judged benign. Matched on (package, "
|
||||
"package-relative path, pattern); evidence/severity are for review "
|
||||
"only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
"package-relative path, pattern, evidence hash); a new payload under "
|
||||
"an already-listed package/path/pattern reopens. severity is for "
|
||||
"review only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": _BASELINE_SCHEMA_VERSION,
|
||||
"entries": entries,
|
||||
|
|
@ -1556,7 +2023,7 @@ def _write_baseline(path: str, findings: list[Finding], threshold_rank: int) ->
|
|||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, 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": 2,
|
||||
"_comment": "scan_npm_packages.py allowlist. Each entry is a HIGH/CRITICAL finding manually judged benign. Matched on (package, package-relative path, pattern, evidence hash); a new payload under an already-listed package/path/pattern reopens instead of riding the entry. severity is 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": 3,
|
||||
"entries": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ False positives:
|
|||
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).
|
||||
(package, package-relative file, check, evidence hash). A new check, or
|
||||
changed flagged code under the same check, reopens the finding; version
|
||||
bumps and line shifts do not. This mirrors the Hugging Face Hub approach
|
||||
(ClamAV/picklescan: low-FP, signature/structural, surface status).
|
||||
|
||||
Exit codes:
|
||||
0 -- no non-baselined CRITICAL or HIGH findings (or --write-baseline)
|
||||
|
|
@ -55,6 +56,8 @@ Exit codes:
|
|||
|
||||
import argparse
|
||||
import atexit
|
||||
import bisect
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
|
@ -156,6 +159,9 @@ RE_EMBEDDED_KEYS = re.compile(
|
|||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Full PEM block (BEGIN..END), used to pin a multiline key body in evidence.
|
||||
RE_PEM_BLOCK = re.compile(r"-----BEGIN[^\n]*KEY-----.*?-----END[^\n]*KEY-----", re.DOTALL)
|
||||
|
||||
# Cloud metadata / IMDS endpoints
|
||||
RE_CLOUD_METADATA = re.compile(
|
||||
r"169\.254\.169\.254" # AWS/Azure/GCP IMDS
|
||||
|
|
@ -476,22 +482,26 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# Large base64 blob
|
||||
if RE_LARGE_BLOB.search(content):
|
||||
blob = RE_LARGE_BLOB.search(content).group()
|
||||
# Digest every blob (not just the first 120 chars, and not just the
|
||||
# first blob), so a later payload that keeps the prefix or appends a
|
||||
# second encoded blob reopens.
|
||||
blob, digest = _blob_digest(content)
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
package,
|
||||
filename,
|
||||
f".pth has large base64-like blob ({len(blob)} chars)",
|
||||
blob[:120] + "...",
|
||||
f"{blob[:120]}... sha256:{digest}",
|
||||
)
|
||||
)
|
||||
|
||||
# Catch-all: any import line in .pth if nothing else triggered
|
||||
# Catch-all: any import line in .pth if nothing else triggered. Bind every
|
||||
# line through a digest so an appended/swapped import reopens the key, but cap
|
||||
# the displayed text so a large .pth of benign-looking imports cannot dump up
|
||||
# to the archive member cap into the logs or baseline JSON.
|
||||
if not findings and import_lines:
|
||||
evidence = "\n".join(import_lines[:5])
|
||||
if len(import_lines) > 5:
|
||||
evidence += f"\n... ({len(import_lines)} import lines total)"
|
||||
evidence = _cap_line("\n".join(import_lines))
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
|
|
@ -505,13 +515,15 @@ def check_pth_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
# Unusually large executable .pth (litellm's was 34 KB; legit ones are <100 bytes)
|
||||
size = len(content)
|
||||
if size > 500 and import_lines:
|
||||
# Pin the content so a different payload of the same size/import count reopens.
|
||||
digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest()
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
f"Unusually large executable .pth ({size} bytes)",
|
||||
f"{len(import_lines)} import line(s) in {size}-byte .pth file",
|
||||
f"{len(import_lines)} import line(s) in {size}-byte .pth file sha256:{digest}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -629,6 +641,13 @@ def _hidden_payload_findings(
|
|||
removed = "".join(o if o != s else " " for o, s in zip(original, code))
|
||||
out = []
|
||||
|
||||
# The visible exec/eval line is what makes the hidden string executable, so
|
||||
# bind it into every finding's evidence: otherwise a reviewed false positive
|
||||
# that keeps the same hidden text but flips a harmless `eval("1+1")` to
|
||||
# `exec(__doc__)` (now running the payload) keeps the same key and stays
|
||||
# suppressed. Taken from `stripped` (real code), where the exec/eval lives.
|
||||
trigger = _extract_evidence(stripped, RE_EXEC_EVAL)
|
||||
|
||||
def _hidden(pat):
|
||||
# Carrier present in a blanked region but NOT in real code. A carrier in
|
||||
# real code is already caught by the normal check, so restricting to
|
||||
|
|
@ -643,7 +662,7 @@ def _hidden_payload_findings(
|
|||
package,
|
||||
filename,
|
||||
"exec/eval with payload hidden in a docstring/string",
|
||||
f"{label}: {_extract_evidence(removed, pat)}",
|
||||
f"exec: {trigger}\n{label}: {_extract_evidence(removed, pat)}",
|
||||
)
|
||||
)
|
||||
# Fetch-then-run dropper: a network call AND an os/subprocess exec that both
|
||||
|
|
@ -657,7 +676,9 @@ def _hidden_payload_findings(
|
|||
package,
|
||||
filename,
|
||||
"exec/eval with hidden network+exec payload",
|
||||
f"network+exec: {_extract_evidence(removed, RE_SUBPROCESS)}",
|
||||
f"exec: {trigger}\n"
|
||||
f"network+exec: {_extract_evidence(removed, RE_NETWORK)} | "
|
||||
f"{_extract_evidence(removed, RE_SUBPROCESS)}",
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
@ -717,14 +738,19 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# openssl encryption + network/key material (encrypted exfiltration)
|
||||
if has_openssl_cli and (has_network or has_keys):
|
||||
# Bind whichever side(s) co-occur so a changed endpoint or key reopens.
|
||||
evidence = [f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}"]
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_keys:
|
||||
evidence.append(f"Key: {_embedded_key_evidence(content)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
package,
|
||||
filename,
|
||||
"openssl encryption + network/key material (encrypted exfiltration)",
|
||||
f"OpenSSL: {_extract_evidence(content, RE_OPENSSL_CLI)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -896,6 +922,10 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
|
||||
# Obfuscated payload: base64 + exec/eval + large blob
|
||||
if has_base64 and has_exec_eval and has_blob:
|
||||
# Digest every blob too: a payload may sit on a separate line from the
|
||||
# decode call, and a second encoded blob may be appended later, so
|
||||
# binding only the base64/exec lines or the first blob would miss it.
|
||||
_, blob_digest = _blob_digest(content)
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
|
|
@ -903,7 +933,8 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
filename,
|
||||
"base64 decode + exec/eval + large encoded blob",
|
||||
f"Base64: {_extract_evidence(content, RE_BASE64)}\n"
|
||||
f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}",
|
||||
f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}\n"
|
||||
f"Blob: sha256:{blob_digest}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -928,32 +959,48 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"Embedded cryptographic key + network calls (encrypted exfil pattern)",
|
||||
f"Key: {_extract_evidence(content, RE_EMBEDDED_KEYS)}\n"
|
||||
f"Key: {_embedded_key_evidence(content)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
|
||||
# Anti-analysis + any other suspicious pattern
|
||||
if has_anti and (has_network or has_subprocess or has_exec_eval):
|
||||
# Bind the suspicious side too so a changed payload reopens.
|
||||
evidence = [f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}"]
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_subprocess:
|
||||
evidence.append(f"Subprocess: {_extract_evidence(content, RE_SUBPROCESS)}")
|
||||
if has_exec_eval:
|
||||
evidence.append(f"Exec: {_extract_evidence(content, RE_EXEC_EVAL)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
"Anti-analysis/sandbox evasion + suspicious behavior",
|
||||
f"Anti: {_extract_evidence(content, RE_ANTI_ANALYSIS)}",
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
# DNS exfiltration with dynamic hostnames
|
||||
if has_dns_exfil and (has_base64 or has_network or has_creds):
|
||||
# Bind the co-occurring side so a changed exfil channel reopens.
|
||||
evidence = [f"DNS: {_extract_evidence(content, RE_DNS_EXFIL)}"]
|
||||
if has_base64:
|
||||
evidence.append(f"Base64: {_extract_evidence(content, RE_BASE64)}")
|
||||
if has_network:
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if has_creds:
|
||||
evidence.append(f"Creds: {_extract_evidence(content, RE_CRED_ACCESS)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
"DNS exfiltration / tunneling patterns",
|
||||
_extract_evidence(content, RE_DNS_EXFIL),
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1064,7 +1111,7 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"Embedded cryptographic key material",
|
||||
_extract_evidence(content, RE_EMBEDDED_KEYS),
|
||||
_embedded_key_evidence(content),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1107,39 +1154,349 @@ def check_py_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
return findings
|
||||
|
||||
|
||||
_MAX_MULTILINE_LINES = 12
|
||||
# How far a single matched call is followed over its bracket continuations. A call
|
||||
# that genuinely closes is bound all the way to its real close, up to the hard
|
||||
# limit, so a ``requests.post(`` with many option/header lines before ``data=``
|
||||
# binds its whole argument list in the digest and a changed payload on a late
|
||||
# continuation line reopens (a 40-line soft cap would hash only the first 40 lines
|
||||
# and let a later ``data=``/headers change ride the baseline key). A bracket that
|
||||
# never closes within the hard limit is a miscount (a multi-line string the
|
||||
# single-line blanker cannot mask) or a stray opener, so it is bound only to the
|
||||
# soft cap and cannot swallow unrelated code.
|
||||
_MAX_CALL_LINES = 40 # soft cap: how far a NEVER-closing opener is followed
|
||||
_MAX_CALL_HARD_LINES = 200 # hard cap: how far a closing call is followed to bind it
|
||||
|
||||
# Cap a single rendered line. A short line is shown verbatim; a long (e.g.
|
||||
# minified one-liner) line is shown as a bounded prefix plus a sha256 of the full
|
||||
# line, so a packed payload cannot dump unbounded content into the evidence and
|
||||
# baseline while a change past the cutoff still changes the digest and reopens the
|
||||
# finding. The npm scanner bounds its snippets the same way.
|
||||
_MAX_LINE_CHARS = 200
|
||||
# Cap on recorded spans in one evidence string; beyond it the remaining spans are
|
||||
# folded into a digest so a file with thousands of matching lines cannot build a
|
||||
# multi-megabyte evidence blob, while an added/removed span past the cap still
|
||||
# changes the key. Comfortably above the largest real baseline entry.
|
||||
_MAX_EVIDENCE_SPANS = 96
|
||||
|
||||
|
||||
def _cap_line(code: str) -> str:
|
||||
"""Bound a single line's displayed code: return it verbatim when short, else a
|
||||
``_MAX_LINE_CHARS`` prefix plus a digest of the whole line so the tail is still
|
||||
pinned (fail-closed) without recording the entire line."""
|
||||
if len(code) <= _MAX_LINE_CHARS:
|
||||
return code
|
||||
digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest()
|
||||
return f"{code[:_MAX_LINE_CHARS]} sha256:{digest}"
|
||||
|
||||
|
||||
_PY_TRIPLE = ("'''", '"""')
|
||||
|
||||
|
||||
def _ends_with_odd_backslash(s: str) -> bool:
|
||||
"""True if ``s`` ends with an odd run of backslashes, i.e. a trailing
|
||||
backslash that escapes the newline (a string/line continuation) rather than a
|
||||
literal ``\\\\`` pair."""
|
||||
return (len(s) - len(s.rstrip("\\"))) % 2 == 1
|
||||
|
||||
|
||||
# Single-line quoted string literal; blanks complete one-line strings (the legacy
|
||||
# view) so the single-line and multi-line blanked spans can be unioned below.
|
||||
_RE_STR_LITERAL = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
|
||||
|
||||
|
||||
def _blank_code_strings(lines: list[str]) -> list[str]:
|
||||
"""Replace string contents (single- and triple-quoted, escapes honoured) with
|
||||
spaces across ``lines``, keeping the line count and every bracket OUTSIDE a
|
||||
string intact. Bracket counting then never miscounts a ``)`` that lives inside
|
||||
a string -- including a triple-quoted string spanning several lines, which a
|
||||
per-line regex cannot blank."""
|
||||
out: list[str] = []
|
||||
in_triple: str | None = None # active ''' or \"\"\" delimiter, or None
|
||||
in_string: str | None = None # active ' or " continued via a trailing backslash
|
||||
for line in lines:
|
||||
buf: list[str] = []
|
||||
i, n = 0, len(line)
|
||||
while i < n:
|
||||
if in_triple is not None:
|
||||
end = line.find(in_triple, i)
|
||||
if end == -1:
|
||||
buf.append(" " * (n - i))
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 3))
|
||||
i = end + 3
|
||||
in_triple = None
|
||||
continue
|
||||
if in_string is not None:
|
||||
# A single-/double-quoted string continued onto this line by a
|
||||
# backslash-escaped newline. Resume blanking until its closing quote;
|
||||
# if this line also ends on an odd trailing backslash the string
|
||||
# continues again, otherwise it closes (or is unterminated) here. A
|
||||
# per-line regex blanker cannot see this, so a `)` on the
|
||||
# continuation line would otherwise be counted as code and close the
|
||||
# call early -- dropping the URL/body lines that follow.
|
||||
j, closed = i, False
|
||||
while j < n:
|
||||
if line[j] == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if line[j] == in_string:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
buf.append(" " * (min(j, n) - i))
|
||||
if closed:
|
||||
in_string = None
|
||||
i = j
|
||||
else:
|
||||
i = n
|
||||
if not _ends_with_odd_backslash(line):
|
||||
in_string = None # unterminated without continuation; stop
|
||||
continue
|
||||
ch = line[i]
|
||||
if ch in "'\"":
|
||||
if line[i : i + 3] in _PY_TRIPLE:
|
||||
delim = line[i : i + 3]
|
||||
end = line.find(delim, i + 3)
|
||||
if end == -1: # opens a triple string that runs past this line
|
||||
buf.append(" " * (n - i))
|
||||
in_triple = delim
|
||||
i = n
|
||||
else:
|
||||
buf.append(" " * (end - i + 3))
|
||||
i = end + 3
|
||||
continue
|
||||
j = i + 1 # single-line string; skip to its closing quote
|
||||
closed = False
|
||||
while j < n:
|
||||
if line[j] == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if line[j] == ch:
|
||||
j += 1
|
||||
closed = True
|
||||
break
|
||||
j += 1
|
||||
buf.append(" " * (min(j, n) - i))
|
||||
if closed:
|
||||
i = j
|
||||
else:
|
||||
# Ran off the line without closing: an odd trailing backslash
|
||||
# escapes the newline and continues the string onto the next
|
||||
# line, so remember the quote; otherwise it is just unterminated.
|
||||
i = n
|
||||
if _ends_with_odd_backslash(line):
|
||||
in_string = ch
|
||||
continue
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
_RE_BRACKETS = re.compile(r"[()\[\]{}]")
|
||||
_OPENERS = frozenset("([{")
|
||||
|
||||
|
||||
def _bracket_lr(line: str) -> tuple[int, int]:
|
||||
"""Order-aware bracket reduction of one already-string-blanked line: ``(L, R)``
|
||||
where ``L`` is the count of closers with no opener earlier on the line (they
|
||||
need an opener to the LEFT / a prior line) and ``R`` is the count of openers
|
||||
with no closer later on the line (they need a closer to the RIGHT / a later
|
||||
line). A plain net count (opens minus closes) collapses order and so masks a
|
||||
trailing opener that follows leading closers on the same line, e.g.
|
||||
``]; requests.post(`` nets to 0 and hides the ``(`` that opens the flagged
|
||||
call; tracking the running minimum keeps that opener visible so the call's
|
||||
argument lines still bind. Only bracket characters are walked (pulled out with
|
||||
one C-level regex pass) so a long minified line stays cheap."""
|
||||
depth = 0
|
||||
low = 0
|
||||
for ch in _RE_BRACKETS.findall(line):
|
||||
if ch in _OPENERS:
|
||||
depth += 1
|
||||
else:
|
||||
depth -= 1
|
||||
if depth < low:
|
||||
low = depth
|
||||
return -low, depth - low
|
||||
|
||||
|
||||
def _scan_line_end(view: list[str], start: int) -> int:
|
||||
"""1-based line where the statement at ``start`` closes its brackets in
|
||||
``view`` (one blanked view of the file). A call that closes is followed to its
|
||||
real close up to ``_MAX_CALL_HARD_LINES`` so its whole argument list binds; a
|
||||
bracket that never closes within that hard limit (a stray/miscounted opener) is
|
||||
bound only to the ``_MAX_CALL_LINES`` soft cap so it cannot swallow the file.
|
||||
Brackets are applied in order via ``_bracket_lr`` (leading closers clamp at 0)
|
||||
so a closer that precedes the opener on the same line does not cancel it."""
|
||||
depth = 0
|
||||
hard = min(len(view), start + _MAX_CALL_HARD_LINES - 1)
|
||||
for j in range(start, hard + 1):
|
||||
ln = view[j - 1]
|
||||
left, right = _bracket_lr(ln)
|
||||
depth = max(0, depth - left) + right
|
||||
if ln.rstrip().endswith("\\"):
|
||||
continue # explicit backslash continuation: the call (e.g. its `(` and
|
||||
# URL/body) is on the next physical line, so do not close here
|
||||
if depth <= 0:
|
||||
return j
|
||||
# Never closed within the hard limit: bind only the soft cap so a stray opener
|
||||
# cannot bind a giant unrelated span.
|
||||
return min(len(view), start + _MAX_CALL_LINES - 1)
|
||||
|
||||
|
||||
def _logical_line_end(sl_blanked: list[str], ml_blanked: list[str], start: int) -> int:
|
||||
"""1-based line where the statement opened at ``start`` closes, so a multi-line
|
||||
call binds its argument lines (a changed URL/body on a continuation line
|
||||
reopens, not just the API line). Returns the LARGER of the spans found in the
|
||||
single-line-blanked view (legacy: a payload embedded inside a string still
|
||||
counts, so its brackets bind the call) and the multi-line-blanked view (a
|
||||
bracket inside a triple-quoted string argument no longer closes the call
|
||||
early). Taking the union never shrinks the bound span below either view, so
|
||||
neither blanking strategy can drop a continuation line a malicious change
|
||||
relies on."""
|
||||
return max(_scan_line_end(sl_blanked, start), _scan_line_end(ml_blanked, start))
|
||||
|
||||
|
||||
def _extract_evidence(
|
||||
content: str,
|
||||
pattern: re.Pattern,
|
||||
max_matches: int = 3,
|
||||
max_matches: int = 0,
|
||||
) -> str:
|
||||
"""Pull matching lines as evidence snippets.
|
||||
"""Pull matching lines as evidence snippets (``max_matches=0`` means all).
|
||||
|
||||
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.
|
||||
Records every matching line in full, not a truncated sample, so an extra
|
||||
match (or extra code on a long line) appended to an already-flagged file
|
||||
changes the evidence and the baseline key instead of riding the first few.
|
||||
Leading whitespace is kept so a flagged line moved out of a guarded block
|
||||
reads as changed. Each single-line match is extended over bracket
|
||||
continuations so a multi-line call binds its argument lines too. Cross-line
|
||||
matches the per-line scan cannot see (DOTALL IOC regexes, or a multi-line
|
||||
construct appended under a check that already had a one-line match) are
|
||||
recorded afterwards, so an added multiline payload reopens the finding. A
|
||||
pathological greedy span is bounded to its head line plus a digest of the
|
||||
rest.
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
matches = []
|
||||
sl_blanked = [_RE_STR_LITERAL.sub("", ln) for ln in lines]
|
||||
ml_blanked = _blank_code_strings(lines)
|
||||
out = []
|
||||
seen: set[tuple[int, int]] = set()
|
||||
# Overflow is streamed, not buffered: once `out` holds _MAX_EVIDENCE_SPANS
|
||||
# rendered spans, every further span is folded straight into a running digest
|
||||
# instead of being materialized and sliced off at the end. On a minified or
|
||||
# padded file with hundreds of thousands of matching lines that keeps memory
|
||||
# and work bounded to the display cap rather than the match count, while the
|
||||
# digest still covers every overflow span so an over-cap payload change
|
||||
# reopens. The fold reproduces _canon_evidence(" | ".join(overflow)) exactly
|
||||
# (strip each span to its non-empty L<NN>-less code lines, join with "\n"), so
|
||||
# the digest is identical to buffering the whole list and canonicalizing once.
|
||||
overflow_count = 0
|
||||
overflow_hash = hashlib.sha256()
|
||||
overflow_started = False
|
||||
|
||||
def _emit(rendered: str) -> None:
|
||||
nonlocal overflow_count, overflow_started
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
out.append(rendered)
|
||||
return
|
||||
overflow_count += 1
|
||||
for piece in _RE_EVIDENCE_SPLIT.split(rendered):
|
||||
piece = _RE_EVIDENCE_PREFIX.sub("", piece, count = 1).rstrip()
|
||||
if not piece:
|
||||
continue
|
||||
if overflow_started:
|
||||
overflow_hash.update(b"\n")
|
||||
overflow_hash.update(piece.encode("utf-8", "replace"))
|
||||
overflow_started = True
|
||||
|
||||
def _render(start: int, end: int) -> str:
|
||||
span = lines[start - 1 : end] or ["<multiline match>"]
|
||||
if len(span) > _MAX_MULTILINE_LINES:
|
||||
# Digest the code without the L<NN>: markers so a pure line shift of
|
||||
# the same span stays stable while a code change still reopens. The
|
||||
# head is truncated for display only; the span digest already binds
|
||||
# its full content, so no per-line digest is needed here.
|
||||
code = "\n".join(ln.rstrip() for ln in span)
|
||||
digest = hashlib.sha256(code.encode("utf-8", "replace")).hexdigest()
|
||||
head = span[0].rstrip()
|
||||
if len(head) > _MAX_LINE_CHARS:
|
||||
head = head[:_MAX_LINE_CHARS] + "..."
|
||||
return f"L{start}: {head} sha256:{digest}"
|
||||
return "\n".join(f"L{start + i}: {_cap_line(ln.rstrip())}" for i, ln in enumerate(span))
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
if pattern.search(line):
|
||||
snippet = line.strip()
|
||||
if len(snippet) > 160:
|
||||
snippet = snippet[:160] + "..."
|
||||
matches.append(f"L{i}: {snippet}")
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
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 ""
|
||||
span = (i, _logical_line_end(sl_blanked, ml_blanked, i))
|
||||
if span in seen:
|
||||
continue
|
||||
# Only track spans while still filling the display list: past the cap
|
||||
# every span is folded into the overflow digest, so growing `seen` with
|
||||
# all of them would keep memory proportional to the match count (the
|
||||
# behavior this cap exists to bound) on a generated file with millions
|
||||
# of one-line matches. The per-line spans are unique by line number, so
|
||||
# dropping them from `seen` past the cap cannot cause a missed dedup
|
||||
# here; at worst the fallback re-folds an over-cap span into the same
|
||||
# digest, which stays deterministic and still reopens on a change.
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
seen.add(span)
|
||||
_emit(_render(*span))
|
||||
if max_matches and len(out) >= max_matches:
|
||||
return " | ".join(out)
|
||||
|
||||
# Precompute newline offsets once so mapping a match offset to its 1-based line
|
||||
# is O(log n) (bisect) rather than O(n) (content.count) per match; the latter
|
||||
# made this fallback quadratic on a minified file with thousands of matches.
|
||||
nl = [p for p, ch in enumerate(content) if ch == "\n"]
|
||||
for m in pattern.finditer(content):
|
||||
start = bisect.bisect_left(nl, m.start()) + 1
|
||||
end = bisect.bisect_left(nl, m.end()) + 1
|
||||
if end <= start or (start, end) in seen:
|
||||
continue # single-line matches are already covered by the pass above
|
||||
# A giant greedy DOTALL span is bound by the full digest of its content
|
||||
# (via _render, which renders a >12-line span as a head line plus a sha256
|
||||
# of the whole span). Binding only the anchors leaves the bridged interior
|
||||
# unhashed, so an attacker could insert a new cross-line payload (a `/tmp`
|
||||
# line and a later `subprocess` line, sharing no single line so the
|
||||
# per-line pass never binds them) between unchanged outer anchors and keep
|
||||
# the same key. Digesting the interior reopens on any such change; a pure
|
||||
# line shift stays stable because the digest is over the markerless code.
|
||||
if len(out) < _MAX_EVIDENCE_SPANS:
|
||||
seen.add((start, end))
|
||||
_emit(_render(start, end))
|
||||
if max_matches and len(out) >= max_matches:
|
||||
break
|
||||
if overflow_count:
|
||||
# The overflow digest was accumulated from the canonicalized (L<NN>:-less)
|
||||
# spans as they were emitted, so a pure line shift above the overflow
|
||||
# region does not change it and reopen an otherwise-unchanged finding,
|
||||
# matching the per-span key's line-shift stability.
|
||||
out.append(f"(+{overflow_count} more) sha256:{overflow_hash.hexdigest()}")
|
||||
return " | ".join(out)
|
||||
|
||||
|
||||
def _embedded_key_evidence(content: str) -> str:
|
||||
"""Key evidence that also pins the full PEM block(s) via a digest, so a key
|
||||
body swapped under the same BEGIN marker reopens the finding (single-line and
|
||||
DER keys are already bound by their full matched line)."""
|
||||
ev = _extract_evidence(content, RE_EMBEDDED_KEYS)
|
||||
blocks = RE_PEM_BLOCK.findall(content)
|
||||
if blocks:
|
||||
digest = hashlib.sha256("\n".join(blocks).encode("utf-8", "replace")).hexdigest()
|
||||
ev = f"{ev} sha256:{digest}" if ev else f"sha256:{digest}"
|
||||
return ev
|
||||
|
||||
|
||||
def _blob_digest(content: str) -> tuple[str, str]:
|
||||
"""First large blob (for display) plus a digest binding EVERY large blob, so
|
||||
an appended or swapped encoded payload reopens the finding rather than riding
|
||||
an unchanged first blob. Assumes at least one blob is present (single-blob
|
||||
files keep the prior single-blob digest, so the baseline does not drift)."""
|
||||
blobs = RE_LARGE_BLOB.findall(content)
|
||||
digest = hashlib.sha256("\n".join(blobs).encode("utf-8", "replace")).hexdigest()
|
||||
return blobs[0], digest
|
||||
|
||||
|
||||
# Non-Python checkers
|
||||
|
|
@ -1189,7 +1546,8 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
package,
|
||||
filename,
|
||||
"JS embeds credential regexes AND makes network calls (stealer)",
|
||||
_extract_evidence(content, RE_TOKEN_REGEX),
|
||||
f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
if has_workflow_inj:
|
||||
|
|
@ -1202,17 +1560,31 @@ def check_js_file(content: str, filename: str, package: str) -> list[Finding]:
|
|||
_extract_evidence(content, RE_WORKFLOW_INJECT),
|
||||
)
|
||||
)
|
||||
if is_large and not findings:
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
f"Python wheel ships large ({len(content) // 1024} KB) JS bundle "
|
||||
"(uncommon; manually review)",
|
||||
"",
|
||||
# Pin the whole file's content digest to EVERY JS finding (not just large
|
||||
# bundles). _extract_evidence blanks only Python string forms before counting
|
||||
# brackets, so a JS backtick template literal that contains `)` can close a
|
||||
# call's span early and omit the option/body lines that follow; binding the
|
||||
# full content means a change to those omitted lines still reopens instead of
|
||||
# riding the matched-line evidence. A large bundle with no other heuristic is a
|
||||
# standalone HIGH.
|
||||
if findings or is_large:
|
||||
digest = hashlib.sha256(content.encode("utf-8", "replace")).hexdigest()
|
||||
if findings:
|
||||
for f in findings:
|
||||
f.evidence = f"{f.evidence} bundle-sha256:{digest}"
|
||||
else:
|
||||
findings.append(
|
||||
Finding(
|
||||
HIGH,
|
||||
package,
|
||||
filename,
|
||||
# Size stays out of the check label (from main) so the baseline
|
||||
# key does not drift when a benign bundle grows; the full-content
|
||||
# digest below still binds the bytes so a payload swap reopens.
|
||||
"Python wheel ships large JS bundle (uncommon; manually review)",
|
||||
f"sha256: {digest}",
|
||||
)
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
|
|
@ -1232,6 +1604,12 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
if RE_DEV_TOOL_HIJACK.search(content) and (
|
||||
RE_NETWORK.search(content) or RE_SUBPROCESS.search(content)
|
||||
):
|
||||
# Bind the hook AND the network/exec signal so a changed exfil reopens.
|
||||
evidence = [f"Hook: {_extract_evidence(content, RE_DEV_TOOL_HIJACK)}"]
|
||||
if RE_NETWORK.search(content):
|
||||
evidence.append(f"Network: {_extract_evidence(content, RE_NETWORK)}")
|
||||
if RE_SUBPROCESS.search(content):
|
||||
evidence.append(f"Exec: {_extract_evidence(content, RE_SUBPROCESS)}")
|
||||
findings.append(
|
||||
Finding(
|
||||
CRITICAL,
|
||||
|
|
@ -1239,7 +1617,7 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
filename,
|
||||
"Shell installs developer-tool persistence hook (.bashrc / "
|
||||
"profile.d / vscode tasks) AND has network or exec",
|
||||
_extract_evidence(content, RE_DEV_TOOL_HIJACK),
|
||||
"\n".join(evidence),
|
||||
)
|
||||
)
|
||||
if RE_TOKEN_REGEX.search(content) and RE_NETWORK.search(content):
|
||||
|
|
@ -1249,7 +1627,8 @@ def check_shell_file(content: str, filename: str, package: str) -> list[Finding]
|
|||
package,
|
||||
filename,
|
||||
"Shell embeds credential regexes AND makes network calls",
|
||||
_extract_evidence(content, RE_TOKEN_REGEX),
|
||||
f"Token: {_extract_evidence(content, RE_TOKEN_REGEX)}\n"
|
||||
f"Network: {_extract_evidence(content, RE_NETWORK)}",
|
||||
)
|
||||
)
|
||||
if RE_WORKFLOW_INJECT.search(content):
|
||||
|
|
@ -2516,9 +2895,9 @@ def _find_requirements_files(root: str) -> list[str]:
|
|||
|
||||
# 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``.
|
||||
# (package, package-relative file, check, evidence hash); the hash strips
|
||||
# ``L<NN>:`` markers so version bumps and line shifts do not reopen an entry,
|
||||
# but changed flagged code does. Regenerate with ``--write-baseline``.
|
||||
|
||||
_DEFAULT_BASELINE_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "scan_packages_baseline.json"
|
||||
|
|
@ -2545,16 +2924,54 @@ def _relpath_in_package(filename: str) -> str:
|
|||
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.
|
||||
# Evidence joins matched spans with " | " and a newline between labelled groups,
|
||||
# each span tagged "L<NN>: ". Split only on those real delimiters (a " | " before
|
||||
# a marker, or a newline), never on a bare "|" -- matched code may contain a
|
||||
# bitwise-or or union type. The prefix strips only a genuine leading marker, an
|
||||
# optional "Label: " then "L<NN>: "; a marker-like "L<NN>:" inside raw code (e.g.
|
||||
# a .pth import line) has no leading marker and is left intact.
|
||||
_RE_EVIDENCE_SPLIT = re.compile(r" \| (?=L\d+:)|\n")
|
||||
_RE_EVIDENCE_PREFIX = re.compile(r"^(?:[A-Za-z][A-Za-z0-9 _/+.-]*:\s*)?L\d+:\s?")
|
||||
|
||||
The package-relative path (not just basename) keeps the key stable across
|
||||
version bumps while still distinguishing same-named files like ``utils.py``.
|
||||
|
||||
def _canon_evidence(evidence: str) -> str:
|
||||
"""Matched code lines in discovery order (markers removed), duplicates kept.
|
||||
|
||||
Splits evidence on its real span delimiters, drops each span's leading
|
||||
label / line-number marker, and keeps the code with its indentation. Line
|
||||
shifts are absorbed by stripping the L<NN>: markers, not by sorting, so order
|
||||
stays significant: reordering matched lines (executable context, e.g. the
|
||||
arguments of a multi-line call) reopens the finding. Keeping duplicates means
|
||||
an appended identical occurrence still changes the key."""
|
||||
spans = []
|
||||
for s in _RE_EVIDENCE_SPLIT.split(evidence or ""):
|
||||
s = _RE_EVIDENCE_PREFIX.sub("", s, count = 1).rstrip()
|
||||
if s:
|
||||
spans.append(s)
|
||||
return "\n".join(spans)
|
||||
|
||||
|
||||
def _evidence_hash(evidence: str) -> str:
|
||||
"""Stable digest of the canonical matched evidence."""
|
||||
return hashlib.sha256(_canon_evidence(evidence).encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def _finding_key(f: Finding) -> tuple[str, str, str, str]:
|
||||
"""Allowlist key: package, package-relative path, check, evidence hash.
|
||||
|
||||
The evidence hash is over the set of matched code, so the key survives version
|
||||
bumps, line shifts and reordering but reopens when the flagged code changes --
|
||||
so a future payload in a baselined file/check is not auto-suppressed.
|
||||
"""
|
||||
return (_norm_pkg(f.package), _relpath_in_package(f.filename), f.check)
|
||||
return (
|
||||
_norm_pkg(f.package),
|
||||
_relpath_in_package(f.filename),
|
||||
f.check,
|
||||
_evidence_hash(f.evidence),
|
||||
)
|
||||
|
||||
|
||||
def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
||||
def _load_baseline(path: str) -> set[tuple[str, 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:
|
||||
|
|
@ -2564,19 +2981,47 @@ def _load_baseline(path: str) -> set[tuple[str, str, str]]:
|
|||
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", []):
|
||||
if not isinstance(data, dict):
|
||||
print(f" [WARN] baseline {path} is not a JSON object", file = sys.stderr)
|
||||
return set()
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
print(f" [WARN] baseline {path} entries is not a list", file = sys.stderr)
|
||||
return set()
|
||||
keys: set[tuple[str, str, str, str]] = set()
|
||||
legacy = 0
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
try:
|
||||
keys.add((_norm_pkg(e["package"]), _relpath_in_package(e["file"]), e["check"]))
|
||||
# Use the reviewed hash; else recompute it from the stored evidence.
|
||||
evidence_hash = e.get("evidence_hash") or _evidence_hash(e.get("evidence") or "")
|
||||
if not e.get("evidence_hash"):
|
||||
legacy += 1
|
||||
keys.add(
|
||||
(
|
||||
_norm_pkg(e["package"]),
|
||||
_relpath_in_package(e["file"]),
|
||||
e["check"],
|
||||
evidence_hash,
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
if legacy:
|
||||
print(
|
||||
f" [WARN] baseline {path}: {legacy} entries lack evidence_hash and may "
|
||||
f"not suppress until regenerated with --write-baseline (findings reopen "
|
||||
f"rather than risk hiding changed code under a coarse key)",
|
||||
file = sys.stderr,
|
||||
)
|
||||
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()
|
||||
seen: set[tuple[str, 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
|
||||
|
|
@ -2590,15 +3035,18 @@ def _write_baseline(path: str, findings: list[Finding]) -> None:
|
|||
"file": _relpath_in_package(f.filename),
|
||||
"check": f.check,
|
||||
"severity": f.severity,
|
||||
"evidence": f.evidence[:240],
|
||||
"evidence": f.evidence,
|
||||
"evidence_hash": _evidence_hash(f.evidence),
|
||||
}
|
||||
)
|
||||
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."
|
||||
"check, evidence_hash); evidence_hash is over the matched code with "
|
||||
"L<NN>: markers stripped, so version bumps and line shifts do not "
|
||||
"reopen an entry but changed code does. severity and evidence are for "
|
||||
"review only. Regenerate with --write-baseline AFTER reviewing every line."
|
||||
),
|
||||
"version": 1,
|
||||
"entries": entries,
|
||||
|
|
@ -2610,7 +3058,7 @@ def _write_baseline(path: str, findings: list[Finding]) -> None:
|
|||
|
||||
|
||||
def _partition_baseline(
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str]]
|
||||
findings: list[Finding], baseline: set[tuple[str, str, str, str]]
|
||||
) -> tuple[list[Finding], list[Finding]]:
|
||||
"""Split findings into (active, suppressed) by allowlist membership."""
|
||||
if not baseline:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,15 +22,64 @@ function Uninstall-UnslothStudio {
|
|||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return }
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
||||
return
|
||||
}
|
||||
# Remove-Item -Recurse can report success yet leave a transiently-locked
|
||||
# child (e.g. unsloth.ico in Explorer's icon cache); verify + retry so we
|
||||
# never falsely claim "removed" or orphan the dir.
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
_Substep "removed: $Path" "Green"
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -lt 3) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
||||
}
|
||||
if ($attempt -lt 4) { Start-Sleep -Milliseconds 700; continue }
|
||||
_Substep "still present (files held open): $Path" "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
# Remove the shared data dir, but keep unsloth.ico if a WSL shortcut still points
|
||||
# at it (else that shortcut blanks); uninstall.sh drops it when WSL is removed.
|
||||
function _RemoveDataDirKeepingWslIcon {
|
||||
param(
|
||||
[string]$DataDir,
|
||||
# WSL-shortcut search dirs; default Start Menu + Desktop, overridable for tests.
|
||||
[string[]]$ShortcutDirs = $null
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($DataDir)) { return }
|
||||
if (-not (Test-Path -LiteralPath $DataDir)) { return }
|
||||
# $null = not passed (use defaults); test $null not truthiness so an explicit
|
||||
# @() is honored (-not @() is $true).
|
||||
if ($null -eq $ShortcutDirs) {
|
||||
# Guard $env:APPDATA: it can be unset in service/CI Windows contexts, where
|
||||
# an unguarded Join-Path emits a noisy parameter-binding error.
|
||||
$ShortcutDirs = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
|
||||
$ShortcutDirs += Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
|
||||
}
|
||||
try {
|
||||
$desktop = [Environment]::GetFolderPath("Desktop")
|
||||
if (-not [string]::IsNullOrWhiteSpace($desktop)) { $ShortcutDirs += $desktop }
|
||||
} catch {}
|
||||
}
|
||||
$wslShortcuts = @()
|
||||
foreach ($d in $ShortcutDirs) {
|
||||
if ($d -and (Test-Path -LiteralPath $d)) {
|
||||
$wslShortcuts += Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio (WSL*.lnk" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if (@($wslShortcuts).Count -eq 0) {
|
||||
_RemovePath $DataDir
|
||||
return
|
||||
}
|
||||
# A WSL shortcut survives: drop everything except its shared icon.
|
||||
_Substep "keeping $(Join-Path $DataDir 'unsloth.ico') for the WSL shortcut" "Gray"
|
||||
Get-ChildItem -LiteralPath $DataDir -Force -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
if ($_.Name -ne "unsloth.ico") { _RemovePath $_.FullName }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +336,9 @@ function Uninstall-UnslothStudio {
|
|||
$defaultUnslothHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth" } else { $null }
|
||||
$defaultLlamaCpp = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "llama.cpp" } else { $null }
|
||||
$defaultCache = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome ".cache" } else { $null }
|
||||
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
|
||||
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
|
||||
$defaultNode = if ($defaultUnslothHome) { Join-Path $defaultUnslothHome "node" } else { $null }
|
||||
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging,
|
||||
# sibling of the install dir). Usually pruned after activate, but an interrupted
|
||||
# build can leave a "<name>.staging-XXXX" tree; removing it lets the empty-dir
|
||||
|
|
@ -310,7 +362,7 @@ function Uninstall-UnslothStudio {
|
|||
_StopStudioProcesses -KnownRoots $knownRoots
|
||||
# Also stop anything holding a handle on the exact paths we delete (llama-server,
|
||||
# the CLI shim, an mp-fork python with a venv DLL) so the dir delete isn't refused.
|
||||
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache))
|
||||
_StopProcessesLockingRoots -Roots (@($knownRoots) + @($defaultDataDir, $defaultLlamaCpp, $defaultCache, $defaultNode))
|
||||
|
||||
# ── Remove custom-root install trees ──
|
||||
_Step "Removing data and install directories..."
|
||||
|
|
@ -328,12 +380,18 @@ function Uninstall-UnslothStudio {
|
|||
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present).
|
||||
if ($defaultStudioHome) { _RemovePath $defaultStudioHome }
|
||||
# Default data dir.
|
||||
if ($defaultDataDir) { _RemovePath $defaultDataDir }
|
||||
if ($defaultDataDir) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
|
||||
# Default-mode shared llama.cpp build + cache (siblings of studio under
|
||||
# ~/.unsloth). No-op in env/custom mode and when absent.
|
||||
if ($defaultLlamaCpp) { _RemovePath $defaultLlamaCpp }
|
||||
if ($defaultCache) { _RemovePath $defaultCache }
|
||||
# Isolated Node.js runtime (sibling of studio under ~/.unsloth). No-op in env/
|
||||
# custom mode (nested under the custom root, removed with it) and when absent.
|
||||
if ($defaultNode) { _RemovePath $defaultNode }
|
||||
if ($defaultStaging) { _RemovePath $defaultStaging }
|
||||
# llama.cpp install lock (serializes the shared build); a stray lock keeps
|
||||
# ~/.unsloth from being pruned below. No-op in env/custom mode and when absent.
|
||||
if ($defaultUnslothHome) { _RemovePath (Join-Path $defaultUnslothHome ".llama.cpp.install.lock") }
|
||||
# Drop ~/.unsloth itself, but ONLY if now empty -- never nuke unrelated content.
|
||||
if ($defaultUnslothHome -and (Test-Path -LiteralPath $defaultUnslothHome) -and
|
||||
-not (Get-ChildItem -LiteralPath $defaultUnslothHome -Force -ErrorAction SilentlyContinue)) {
|
||||
|
|
@ -366,6 +424,11 @@ function Uninstall-UnslothStudio {
|
|||
}
|
||||
} catch { }
|
||||
|
||||
# Re-sweep: the first pass may have left unsloth.ico locked by Explorer/SMEH for
|
||||
# the native shortcut; that handle is now freed. (A surviving WSL shortcut still
|
||||
# keeps the icon -- see the helper.)
|
||||
if ($defaultDataDir -and (Test-Path -LiteralPath $defaultDataDir)) { _RemoveDataDirKeepingWslIcon $defaultDataDir }
|
||||
|
||||
# ── Clean user PATH and registry backup ──
|
||||
_Step "Cleaning user PATH and registry..."
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -219,10 +219,16 @@ _remove_path "$HOME/.unsloth/llama.cpp"
|
|||
# provision_llama_cuda.sh fetched by the WoA/Spark CUDA-build path. No-op when absent.
|
||||
_remove_path "$HOME/.unsloth/provision_llama_cuda.sh"
|
||||
_remove_path "$HOME/.unsloth/.cache"
|
||||
# Isolated Node.js runtime (install_node_prebuilt.py), a sibling of studio in
|
||||
# default mode. No-op in env/custom mode (nested under the custom root) and absent.
|
||||
_remove_path "$HOME/.unsloth/node"
|
||||
# llama.cpp atomic-install staging root (install_llama_prebuilt.py .staging).
|
||||
# Normally pruned after activate, but an interrupted build can leave it behind;
|
||||
# removing it lets the rmdir below succeed. No-op in env/custom mode and absent.
|
||||
_remove_path "$HOME/.unsloth/.staging"
|
||||
# llama.cpp install lock (serializes the shared build); a stray one keeps ~/.unsloth
|
||||
# from being pruned below. No-op in env/custom mode and when absent.
|
||||
_remove_path "$HOME/.unsloth/.llama.cpp.install.lock"
|
||||
# ROCm-on-WSL helper artifacts (librocdxg build clone + smoke-test venv). No-op
|
||||
# where they don't exist; removing them lets the rmdir below succeed.
|
||||
_remove_path "$HOME/.unsloth/librocdxg"
|
||||
|
|
@ -315,11 +321,50 @@ case "$_os" in
|
|||
$up = [Environment]::GetEnvironmentVariable("Path","User");
|
||||
if ($up) { [Environment]::SetEnvironmentVariable("Path", (($up -split ";" | Where-Object { $_ -and ($_.TrimEnd("\","/") -ine $shim) }) -join ";"), "User") }
|
||||
if (Test-Path -LiteralPath $ud) { Remove-Item -LiteralPath $ud -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
# Keep the shared icon while any Unsloth shortcut still uses it (native
|
||||
# install or another WSL distro); drop it only with the last one.
|
||||
$iconInUse = $false;
|
||||
foreach ($d in $dirs) {
|
||||
if (-not $d -or -not (Test-Path -LiteralPath $d)) { continue }
|
||||
if (Get-ChildItem -LiteralPath $d -Filter "Unsloth Studio*.lnk" -ErrorAction SilentlyContinue) { $iconInUse = $true; break }
|
||||
}
|
||||
# Guard LOCALAPPDATA: empty on a service/SYSTEM account makes
|
||||
# Join-Path throw, aborting the icon cleanup (mirror uninstall.ps1).
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
$iconDir = Join-Path $env:LOCALAPPDATA "Unsloth Studio";
|
||||
$ico = Join-Path $iconDir "unsloth.ico";
|
||||
if ((-not $iconInUse) -and (Test-Path -LiteralPath $ico)) { Remove-Item -LiteralPath $ico -Force -ErrorAction SilentlyContinue }
|
||||
if ((Test-Path -LiteralPath $iconDir) -and -not (Get-ChildItem -LiteralPath $iconDir -Force -ErrorAction SilentlyContinue)) { Remove-Item -LiteralPath $iconDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Fallback when powershell.exe can't run (interop disabled): remove the
|
||||
# WSL .lnk files via drvfs. The "Unsloth Studio (WSL..." name is
|
||||
# WSL-specific, so a native install's "Unsloth Studio.lnk" never matches.
|
||||
# Remove $1's shared unsloth.ico only if no Unsloth shortcut (native install
|
||||
# or another WSL distro) still uses it, then drop the dir if empty. Reciprocal
|
||||
# of uninstall.ps1's _RemoveDataDirKeepingWslIcon (keeps the icon for a
|
||||
# surviving WSL shortcut when the native side is removed).
|
||||
_drop_shared_icon_if_unused() {
|
||||
_du="$1"
|
||||
_icodir="$_du/AppData/Local/Unsloth Studio"
|
||||
_icon_in_use=0
|
||||
for _sd in \
|
||||
"$_du/Desktop" \
|
||||
"$_du/OneDrive/Desktop" \
|
||||
"$_du"/OneDrive*/Desktop \
|
||||
"$_du/AppData/Roaming/Microsoft/Windows/Start Menu/Programs"; do
|
||||
[ -d "$_sd" ] || continue
|
||||
for _any in "$_sd"/"Unsloth Studio"*.lnk; do
|
||||
[ -e "$_any" ] && { _icon_in_use=1; break; }
|
||||
done
|
||||
[ "$_icon_in_use" = "1" ] && break
|
||||
done
|
||||
if [ "$_icon_in_use" = "0" ]; then
|
||||
[ -f "$_icodir/unsloth.ico" ] && rm -f "$_icodir/unsloth.ico" 2>/dev/null || true
|
||||
fi
|
||||
[ -d "$_icodir" ] && rmdir "$_icodir" 2>/dev/null || true
|
||||
}
|
||||
# Fallback when powershell.exe can't run (interop disabled): remove WSL .lnk
|
||||
# files via drvfs. The "Unsloth Studio (WSL..." name is WSL-specific, so a
|
||||
# native install's "Unsloth Studio.lnk" never matches.
|
||||
if [ "$_ps_ran" = "0" ]; then
|
||||
for _drive in /mnt/c /mnt/d /mnt/e; do
|
||||
[ -d "$_drive/Users" ] || continue
|
||||
|
|
@ -342,6 +387,8 @@ case "$_os" in
|
|||
done
|
||||
fi
|
||||
done
|
||||
# Drop the shared icon only when no shortcut still needs it.
|
||||
_drop_shared_icon_if_unused "$_udir"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -564,6 +564,12 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
|
|||
for n, tids in b["module_import_targets"].items():
|
||||
if tids & after_used:
|
||||
continue # resolved -> fine
|
||||
# `from __future__ import ...` is a compiler directive, not a runtime
|
||||
# binding: the name (`annotations`, ...) is never loaded, so it can never
|
||||
# "resolve" to a use. Skip it so a legitimately-added future import
|
||||
# (e.g. `annotations` for lazy PEP 604 `X | None` on py3.9) is not flagged.
|
||||
if all(t.startswith("from:__future__:") for t in tids):
|
||||
continue
|
||||
newly_added = bool(tids - before_module_targets)
|
||||
was_used_before = bool(tids & before_used)
|
||||
if newly_added or was_used_before:
|
||||
|
|
@ -581,9 +587,30 @@ def compare(before_src: str, after_src: str, path: str) -> list[tuple[str, str]]
|
|||
)
|
||||
|
||||
# 3. TARGET-CHANGED (same scope+name resolves to a different import target)
|
||||
# Only a *swap* is dangerous: a BEFORE target that is no longer reachable in
|
||||
# AFTER means a reference was silently re-pointed. A pure superset growth
|
||||
# (tbefore <= tafter) is the benign `import pkg.subA` + `import pkg.subB`
|
||||
# case: both statements bind the same top-level name `pkg` to the same
|
||||
# package object and only *add* submodule attributes (e.g. adding
|
||||
# `import urllib.error` next to `import urllib.request`). Nothing the name
|
||||
# resolved to before is lost, so no reference is re-pointed -- skip it.
|
||||
#
|
||||
# A deliberate *relocation* is also benign and must not block: when a name
|
||||
# keeps its spelling but its import source is moved A -> B in THIS diff (the
|
||||
# old `from A import x` is removed at module level and a new `from B import x`
|
||||
# is added), the swap is intentional, not a silent re-point to a pre-existing
|
||||
# different object. This mirrors the relocation tolerance already applied to
|
||||
# TARGET-MISSING. The dangerous case -- the name now resolving to a target
|
||||
# that already existed before (shadow/clash) -- is NOT exempted.
|
||||
removed_module_targets = before_module_targets - after_module_targets
|
||||
for key, tafter in b["target_by_use"].items():
|
||||
tbefore = a["target_by_use"].get(key)
|
||||
if tbefore and tbefore != tafter:
|
||||
if tbefore and tbefore != tafter and (tbefore - tafter):
|
||||
lost = tbefore - tafter
|
||||
gained = tafter - tbefore
|
||||
relocated = lost <= removed_module_targets and gained <= added_module_targets
|
||||
if relocated:
|
||||
continue
|
||||
findings.append(
|
||||
(
|
||||
"BLOCKER",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue