diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa7ecb..6fbbb75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ ## Unreleased +- Second review round (PR#29 delta findings): custom package sources + (`--registry`, `--index-url`, `--find-links`, `--no-index`, composer + `--repository`) now floor the verdict at WARN for every ecosystem — exact + versions included — because public advisory data cannot vouch for a + private artifact of the same name@version; the operator lifts the floor + per source via `install.trusted_registries` (or per version via a pinned + host-allow). `safe install` and the uv wrapper thread the same selectors. + OSV pagination rejects non-string/repeated tokens (a malformed + completeness token no longer reads as zero advisories). SemVer prerelease + identifiers compare per spec (identifier-by-identifier, numeric before + alphanumeric) instead of lexically, and version-qualified `overrides` keys + (`"pkg@^2.0.0"`) degrade resolution like bare ones. An adverse advisory + result revokes recorded clean install-known evidence even when a pinned + host-allow permits the current invocation, and the stale readers use the + canonical ecosystem key (cargo/composer entries are findable again). + +- Orthogonal-review hardening of the version-aware gate (PR#29 findings): + version-scoped OSV results are server-authoritative — the local range + comparator annotates but can never downgrade a hit to GO; OSV pagination is + followed (a token-only page no longer reads as zero advisories, anomalies + fail closed); target-altering selectors are threaded into the audit + (`--tag` → dist-tag resolution, `--registry` → packument source, + `--prefix`/`--working-dir` → project dir; custom pip/uv/cargo/composer + indexes degrade to the unresolved refusal instead of auditing the default + registry); the npm resolver follows npm-pick-manifest's latest-tag + preference, no longer globs range tokens against cwd files, and degrades + when root `overrides` mention the package; a WARN/BLOCK check revokes any + stale install-known entry for that version and the timeout fallback only + accepts verdict-GO evidence; severity now normalizes from qualitative + labels AND standard CVSS v3 vectors (computed base score, most severe + wins; v4-only floors at high); `cargo`/`composer` ecosystem labels map to + their resolvers and OSV names, and `cargo install --version X` audits the + pinned crate. + +- Version-aware install verdicts: `safe audit check` now resolves the version + the package manager would actually install (exact spec as-is; npm dist-tag + for installs; the **in-range** target from `package.json`/lockfile ranges + for `--op update`; registry latest for pip/uv, cargo, composer, go) and + matches OSV advisories against that exact version's affected ranges. + Previously the query sent `version:"latest"`, OSV returned every advisory + ever filed, and the verdict was a version-blind count — which WARN-blocked + the very bumps that remediate a CVE (inbox 2026-07-31, third occurrence). + Advisories are classified `affecting`/`remediated`/`unfixed`/`ambiguous`; + only affecting ones drive the verdict, with `install.block_severities` + (default critical) escalating to BLOCK. Resolution failure degrades to a + package-level audit with a WARN floor (`version_unresolved`), and an OSV + outage now fails closed (it used to count as zero CVEs). Exit codes and + plain `check` semantics are unchanged for consumers. + +- New install gate mode (`safe audit check --gate install`), used by the zsh + wrappers and `safe install`: GO proceeds with no operator terminal and + records the pinned resolved version in machine-written + `~/.config/safe/run/install-known.json` (evidence pointing at the check + receipt; consulted only as an offline fallback when the audit times out, + within `install.auto_allow_ttl_days`). WARN proceeds only on a pinned + host-allow entry matching the **resolved** version — fixing the dead end + where `npm update` audited `pkg@latest` and a pinned allow could never + match — or via the opt-in `install.auto_allow_tolerate` causes. Refusal + hints are always pinned (`host-allow add @`); nothing ever + suggests `@latest`. Socket scoring failures refuse with an explicit + infrastructure-failure message and recovery path (`socket login`, + `safe doctor` now reports the Socket CLI/token wiring), clearly + distinguished from a package finding. Wrapper and `safe install` gate + decisions now leave a persistent record in the safe-run audit log + (`install: | ... | GATE | ... | PROCEED/REFUSED_*`). + - Add `safe vendor update --preset ` for claude, gh, op, uv, and codex: fills `--name`, `--path` (auto-detected on PATH), and `--version-cmd` so a native vendor update needs only `--reason` and the command; explicit diff --git a/bin/safe b/bin/safe index 82cd3f2..f37d8de 100755 --- a/bin/safe +++ b/bin/safe @@ -424,6 +424,15 @@ doctor_payload_json() { sandbox_podman_json=$(command_presence_json podman --version) sandbox_timeout_json=$(command_presence_json timeout) + # Socket wiring: a failing socket score WARN-refuses installs, so the + # doctor must make the auth/injection chain verifiable in one command. + local socket_cmd socket_mapping="$HOME/.config/setup-new-machines/bw-env.d/socket.env" + socket_cmd=$(PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH" command -v socket 2>/dev/null || true) + local socket_cli_present=false socket_token_env_set=false socket_vault_mapping_present=false + [[ -n "$socket_cmd" ]] && socket_cli_present=true + [[ -n "${SOCKET_SECURITY_API_TOKEN:-}" ]] && socket_token_env_set=true + [[ -f "$socket_mapping" ]] && socket_vault_mapping_present=true + local capabilities_json="" capabilities_available=false local capabilities_version="" local capability_verify_release_asset=false @@ -575,7 +584,12 @@ doctor_payload_json() { --argjson safe_run_config_present "$( [[ -f "$run_config_dir/config.json" ]] && printf 'true' || printf 'false' )" \ --argjson safe_run_host_allow_file "$( [[ -f "$run_config_dir/host-allow.json" ]] && printf 'true' || printf 'false' )" \ --argjson safe_run_sandbox_known_file "$( [[ -f "$run_config_dir/sandbox-known.json" ]] && printf 'true' || printf 'false' )" \ + --argjson safe_run_install_known_file "$( [[ -f "$run_config_dir/install-known.json" ]] && printf 'true' || printf 'false' )" \ --argjson safe_run_blocked_file "$( [[ -f "$run_config_dir/blocked.json" ]] && printf 'true' || printf 'false' )" \ + --arg socket_cli_path "${socket_cmd:-}" \ + --argjson socket_cli_present "$socket_cli_present" \ + --argjson socket_token_env_set "$socket_token_env_set" \ + --argjson socket_vault_mapping_present "$socket_vault_mapping_present" \ --argjson linked_npx "$linked_npx" \ --argjson linked_bunx "$linked_bunx" \ --argjson linked_uvx "$linked_uvx" \ @@ -635,6 +649,7 @@ doctor_payload_json() { config_present: $safe_run_config_present, host_allow_file: $safe_run_host_allow_file, sandbox_known_file: $safe_run_sandbox_known_file, + install_known_file: $safe_run_install_known_file, blocked_file: $safe_run_blocked_file, linked_runners: { npx: $linked_npx, @@ -651,6 +666,12 @@ doctor_payload_json() { install_wrappers: { script_present: $wrapper_present, completion_present: $completion_present + }, + socket: { + cli_present: $socket_cli_present, + cli_path: $socket_cli_path, + token_env_set: $socket_token_env_set, + vault_mapping_present: $socket_vault_mapping_present } } }' @@ -678,6 +699,10 @@ print_doctor_human() { " binary exec: " + (if .features.binary_exec.ready then "ready" else "not ready" end), " safe run sandbox: " + (if .features.safe_run_sandbox.ready then "ready" else "not ready" end), "", + "socket wiring (install gate scoring):", + " cli: " + (if .environment.socket.cli_present then .environment.socket.cli_path else "MISSING — install socket CLI" end), + " token: " + (if .environment.socket.token_env_set then "env" elif .environment.socket.vault_mapping_present then "vault mapping" else "MISSING — socket login or add bw-env socket mapping" end), + "", "missing prerequisites:", ( [ @@ -785,7 +810,7 @@ safe_install_npm_packages() { -g|--global|-f|--force|-D|--dev|-P|--peer|-O|--optional|-E|--exact|-T|--tilde|--cached|--save|--save-prod|--save-dev|--save-optional|--save-peer|--no-save|--prefer-offline|--prefer-online|--legacy-peer-deps|--strict-peer-deps|--dry-run|--package-lock-only|--workspace-root|--ignore-scripts|--foreground-scripts) continue ;; - --tag|--registry|--cache|--prefix|--userconfig|--globalconfig|--workspace|-w|--filter|--omit|--include|--install-strategy|--save-prefix|--mode|--cwd) + --tag|--registry|--@*:registry|--cache|--prefix|--userconfig|--globalconfig|--workspace|-w|--filter|--omit|--include|--install-strategy|--save-prefix|--mode|--cwd) skip_next=1 continue ;; @@ -828,7 +853,7 @@ safe_install_npm_spec() { version="latest" fi - printf '%s@%s\n' "$name" "$version" + safe_install_print_spec "$name" "$version" } safe_install_colon_spec() { @@ -842,7 +867,20 @@ safe_install_colon_spec() { version="latest" fi - printf '%s@%s\n' "$name" "$version" + safe_install_print_spec "$name" "$version" +} + +# An unpinned request is audited as the bare name: safe audit resolves the +# real target version itself (a fabricated @latest is version-blind and made +# pinned host-allow entries unmatchable). +safe_install_print_spec() { + local name="$1" version="$2" + + if [[ -z "$version" || "$version" == "latest" ]]; then + printf '%s\n' "$name" + else + printf '%s@%s\n' "$name" "$version" + fi } safe_install_composer_packages() { @@ -1006,6 +1044,148 @@ safe_install_host_allow_matches() { [[ "$entry_version" == "$version" && "$entry_ecosystem" == "npm" ]] } +# Persistent record of install-gate decisions, mirroring safe-run's audit_log +# field format (ts | runner | pkg | tier | context | decision | extra). +safe_install_gate_log() { + local ecosystem="$1" package="$2" decision="$3" extra="${4:-}" + local data_dir="${SAFE_RUN_DATA_DIR:-${SAFE_DATA_DIR:-$HOME/.local/share/safe}/run}" + local context="non-tty" + [[ -t 0 && -t 1 ]] && context="interactive" + mkdir -p "$data_dir" 2>/dev/null || return 0 + local line + line="$(date -Iseconds) | install:${ecosystem} | ${package} | GATE | ${context} | ${decision}${extra:+ | ${extra}}" + printf '%s\n' "${line//[$'\r\n']/ }" >> "$data_dir/audit.log" 2>/dev/null || true +} + +# Offline fallback: an exact-version request matching a fresh install-known +# entry (previously recorded clean check) may proceed when the audit itself +# timed out. Unpinned requests never qualify. +safe_install_known_matches() { + local package="$1" ecosystem="$2" + local known_file name version first_allowed ttl_days entry_epoch now_epoch + # install-known entries are keyed by the canonical resolver ecosystem. + case "$ecosystem" in + cargo) ecosystem="rust" ;; + composer) ecosystem="php" ;; + pip|uv) ecosystem="python" ;; + esac + known_file="${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}/install-known.json" + + command -v jq >/dev/null 2>&1 || return 1 + [[ -r "$known_file" ]] || return 1 + + IFS=$'\t' read -r name version <<< "$(safe_install_split_spec "$package")" + [[ -n "$name" && -n "$version" && "$version" != "latest" ]] || return 1 + + # Only fully clean GO evidence may satisfy the offline fallback — a + # tolerated-WARN record is not clean evidence (PR#29 review finding 5). + # The source identity (implicit-default vs explicit:, delta-3 + # finding 5) must be byte-identical to what safe-audit's receipt writer + # recorded; the derivation (argv + env + rc precedence, per ecosystem) is + # centralized in safe-audit — ask it instead of mirroring (delta-4 + # findings 3.2 and N1). Local-only plumbing, no network. No identity -> + # fail closed: stale evidence never applies without a trustworthy match. + [[ -x "$SAFE_AUDIT_PATH" ]] || return 1 + local -a es_args=("$name" --ecosystem "$ecosystem") + [[ -n "${SAFE_INSTALL_REGISTRY:-}" ]] && es_args+=(--registry "$SAFE_INSTALL_REGISTRY") + [[ -n "${SAFE_INSTALL_PROJECT_DIR:-}" ]] && es_args+=(--project-dir "$SAFE_INSTALL_PROJECT_DIR") + [[ -n "${SAFE_INSTALL_NPM_USERCONFIG:-}" ]] && es_args+=(--npm-userconfig "$SAFE_INSTALL_NPM_USERCONFIG") + [[ -n "${SAFE_INSTALL_NPM_GLOBALCONFIG:-}" ]] && es_args+=(--npm-globalconfig "$SAFE_INSTALL_NPM_GLOBALCONFIG") + local current_source + current_source="$("$SAFE_AUDIT_PATH" effective-sources "${es_args[@]}" 2>/dev/null)" || return 1 + [[ -n "$current_source" ]] || return 1 + first_allowed="$(jq -r --arg k "${ecosystem}:${name}" --arg v "$version" --arg src "$current_source" \ + '.packages[$k] | select(.version == $v and .verdict == "GO" and ((.source // "implicit-default") == $src)) | .first_allowed // empty' "$known_file" 2>/dev/null || true)" + [[ -n "$first_allowed" ]] || return 1 + + ttl_days="$(jq -r '.install.auto_allow_ttl_days // 30' \ + "${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}/config.json" 2>/dev/null || echo 30)" + [[ "$ttl_days" =~ ^[0-9]+$ ]] || ttl_days=30 + entry_epoch="$(date -d "$first_allowed" +%s 2>/dev/null || true)" + [[ -n "$entry_epoch" ]] || return 1 + now_epoch="$(date +%s)" + (( now_epoch - entry_epoch <= ttl_days * 86400 )) +} + +# Source selectors are cumulative: accumulate every one — a later trusted +# selector must never erase an earlier untrusted one (delta-2 finding 3). +# A REPEATED selector moves to the END: npm options are last-wins and the +# resolver reads the last word (delta-4 finding 3.1); trailing slashes +# normalize away so writer and readers derive identical identities (N1). +safe_install_add_source() { + local value="$1" + while [[ "$value" == */ ]]; do value="${value%/}"; done + # The accumulated set stays OPERATIONAL (credentials included): safe-audit + # fetches packuments from it, and pre-redacting here broke authenticated + # registries (delta-6 finding N2). Redaction happens centrally in + # safe-audit at every identity/receipt/display sink. + [[ -n "$value" ]] || return 0 + local out="" w + for w in ${SAFE_INSTALL_REGISTRY}; do + [[ "$w" == "$value" ]] || out="${out:+$out }$w" + done + SAFE_INSTALL_REGISTRY="${out:+$out }$value" +} + +# Alternate npm config files are threaded to safe-audit AS PATHS, never +# flattened into the source set: extracting their values here lost the +# key/scope and npm's config-tier precedence (wrong-registry resolution), +# and copied any inline credentials out of the protected file into argv +# (delta-7 findings 3.2b and N2). safe-audit resolves the effective +# endpoint from the file itself. + +# Target-altering selectors in the manager args must reach the audit +# (PR#29 delta finding 3): scan and thread exactly like the shell wrappers. +safe_install_scan_target_flags() { + local family="$1" + shift + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + local prev="" arg + for arg in "$@"; do + case "$family" in + npm) + case "$prev" in + --tag) SAFE_INSTALL_DIST_TAG="$arg" ;; + # --@scope:registry is a scoped source selector npm accepts on the + # command line (delta-5 finding 3.2). It is threaded as a KEYED + # token (@scope:registry=URL) — flattening the bare URL loses the + # key and lets the resolver pick a different scope's registry + # (delta-6 finding 3.2b). --userconfig/--globalconfig swap in + # whole config files whose registry keys become effective. + --registry) safe_install_add_source "$arg" ;; + --@*:registry) safe_install_add_source "${prev#--}=${arg}" ;; + --userconfig) SAFE_INSTALL_NPM_USERCONFIG="$arg" ;; + --globalconfig) SAFE_INSTALL_NPM_GLOBALCONFIG="$arg" ;; + --prefix|-C|--cwd|--dir) SAFE_INSTALL_PROJECT_DIR="$arg" ;; + esac + case "$arg" in + --tag=*) SAFE_INSTALL_DIST_TAG="${arg#*=}" ;; + --registry=*) safe_install_add_source "${arg#*=}" ;; + --@*:registry=*) safe_install_add_source "${arg#--}" ;; + --userconfig=*) SAFE_INSTALL_NPM_USERCONFIG="${arg#*=}" ;; + --globalconfig=*) SAFE_INSTALL_NPM_GLOBALCONFIG="${arg#*=}" ;; + --prefix=*|--cwd=*|--dir=*) SAFE_INSTALL_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + composer) + case "$prev" in + --repository) safe_install_add_source "$arg" ;; + --working-dir|-d) SAFE_INSTALL_PROJECT_DIR="$arg" ;; + esac + case "$arg" in + --repository=*) safe_install_add_source "${arg#*=}" ;; + --working-dir=*) SAFE_INSTALL_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + esac + prev="$arg" + done +} + safe_install_audit_package() { local package="$1" local ecosystem="$2" @@ -1013,37 +1193,66 @@ safe_install_audit_package() { [[ -x "$SAFE_AUDIT_PATH" ]] || die "safe install requires safe audit at $SAFE_AUDIT_PATH" + local -a extra=() + [[ -n "${SAFE_INSTALL_DIST_TAG:-}" ]] && extra+=(--dist-tag "$SAFE_INSTALL_DIST_TAG") + [[ -n "${SAFE_INSTALL_REGISTRY:-}" ]] && extra+=(--registry "$SAFE_INSTALL_REGISTRY") + [[ -n "${SAFE_INSTALL_PROJECT_DIR:-}" ]] && extra+=(--project-dir "$SAFE_INSTALL_PROJECT_DIR") + [[ -n "${SAFE_INSTALL_NPM_USERCONFIG:-}" ]] && extra+=(--npm-userconfig "$SAFE_INSTALL_NPM_USERCONFIG") + [[ -n "${SAFE_INSTALL_NPM_GLOBALCONFIG:-}" ]] && extra+=(--npm-globalconfig "$SAFE_INSTALL_NPM_GLOBALCONFIG") + set +e - "$SAFE_AUDIT_PATH" check "$package" --ecosystem "$ecosystem" + "$SAFE_AUDIT_PATH" check "$package" --ecosystem "$ecosystem" --gate install --op install ${extra[@]+"${extra[@]}"} audit_status=$? set -e case "$audit_status" in 0) + safe_install_gate_log "$ecosystem" "$package" "PROCEED" return 0 ;; 1|10) if [[ "$ecosystem" == "npm" ]] && safe_install_host_allow_matches "$package"; then err "safe audit warned for $package; exact host-allow entry permits install" + safe_install_gate_log "$ecosystem" "$package" "HOST_ALLOW_OVERRIDE" return 0 fi + safe_install_gate_log "$ecosystem" "$package" "REFUSED_WARN" refuse 100 "BLOCKED ${ecosystem} install of ${package} — safe audit verdict WARN; $(safe_install_allow_hint "$package" "$ecosystem"); details: safe explain" ;; 2|20) + safe_install_gate_log "$ecosystem" "$package" "REFUSED_BLOCK" refuse 104 "BLOCKED ${ecosystem} install of ${package} — safe audit verdict BLOCK; $(safe_install_allow_hint "$package" "$ecosystem"); details: safe explain" ;; + 124|137) + if safe_install_known_matches "$package" "$ecosystem"; then + err "safe audit timed out; proceeding on recorded clean check for $package (stale evidence)" + safe_install_gate_log "$ecosystem" "$package" "STALE_EVIDENCE" + return 0 + fi + safe_install_gate_log "$ecosystem" "$package" "TIMEOUT_FAILCLOSED" + refuse 100 "BLOCKED ${ecosystem} install of ${package} — safe audit timed out (fail closed); retry or ask the operator; details: safe explain" + ;; *) + safe_install_gate_log "$ecosystem" "$package" "REFUSED_AUDIT_ERROR" "exit=${audit_status}" refuse 100 "BLOCKED ${ecosystem} install of ${package} — safe audit failed with exit ${audit_status} (fail closed); ask the operator; details: safe explain" ;; esac } +# Fallback hint when the audit could not supply a pinned suggestion (the gate +# prints resolved-version hints itself). Never renders an @latest shape. safe_install_allow_hint() { local package="$1" local ecosystem="$2" + local name version + IFS=$'\t' read -r name version <<< "$(safe_install_split_spec "$package")" if [[ "$ecosystem" == "npm" ]]; then - printf 'to allow: ask the operator to run: safe run host-allow add %s --reason "..." — then retry' "$package" + if [[ -n "$version" && "$version" != "latest" ]]; then + printf 'to allow: ask the operator to run: safe run host-allow add %s --reason "..." — then retry' "$package" + else + printf 'to allow: pin an exact version first (see the resolved-version hint above) — allow entries are never @latest' + fi else printf 'to allow: operator review — safe audit check %s --ecosystem %s' "$package" "$ecosystem" fi @@ -1167,6 +1376,10 @@ cmd_install() { [[ "${#audit_packages[@]}" -gt 0 ]] || die "usage: safe install --host [package-manager flags...] [...]" ecosystem="$(safe_install_ecosystem "$manager")" + case "$manager" in + composer) safe_install_scan_target_flags composer "${manager_args[@]}" ;; + *) safe_install_scan_target_flags npm "${manager_args[@]}" ;; + esac for audit_package in "${audit_packages[@]}"; do safe_install_audit_package "$audit_package" "$ecosystem" if safe_install_trustable_package "$audit_package" "$ecosystem"; then diff --git a/bin/safe-audit b/bin/safe-audit index 3f3928e..3eea5ff 100755 --- a/bin/safe-audit +++ b/bin/safe-audit @@ -3512,6 +3512,12 @@ parse_pkg_spec() { if [[ "$spec" == @*/*@* ]]; then PKG_NAME="${spec%@*}" PKG_VERSION="${spec##*@}" + elif [[ "$spec" == @* ]]; then + # A bare scoped name (@scope/pkg) has no version separator — its + # leading @ is part of the name, not a split point (delta-6, exposed by + # the scoped-key resolution tests). + PKG_NAME="$spec" + PKG_VERSION="latest" elif [[ "$spec" == *@* ]]; then PKG_NAME="${spec%@*}" PKG_VERSION="${spec##*@}" @@ -3621,25 +3627,1183 @@ osv_query_package_json() { go) eco="Go" ;; *) eco="$ecosystem" ;; esac - if ! curl -fsS https://api.osv.dev/v1/query \ - -H 'content-type: application/json' \ - -d "$(jq -n --arg eco "$eco" --arg name "$name" --arg version "$version" '{package:{ecosystem:$eco,name:$name},version:$version}')" \ - > "$output.raw"; then - printf '{"status":"error","note":"OSV query failed","vulns":[]}\n' > "$output" + # /v1/query is paginated: a successful page may carry only next_page_token + # with the findings on a later page. Treating one page as the full answer + # would let a token-only page read as "zero advisories" — follow the token + # chain, merge, and fail closed on any anomaly (bad page, non-array vulns, + # repeated token, unbounded chain). + local base_body body page_token="" vulns='[]' pages=0 seen_tokens="" + if [[ -n "$version" && "$version" != "latest" ]]; then + base_body=$(jq -n --arg eco "$eco" --arg name "$name" --arg version "$version" '{package:{ecosystem:$eco,name:$name},version:$version}') + else + # Package-only query: returns every advisory ever filed for the package. + # Only used on the degraded path when no target version could be resolved. + base_body=$(jq -n --arg eco "$eco" --arg name "$name" '{package:{ecosystem:$eco,name:$name}}') + fi + while :; do + pages=$((pages + 1)) + if (( pages > 10 )); then + printf '{"status":"error","note":"OSV pagination exceeded 10 pages","vulns":[]}\n' > "$output" + return 0 + fi + if [[ -n "$page_token" ]]; then + body=$(jq -cn --argjson base "$base_body" --arg token "$page_token" '$base + {page_token: $token}') + else + body="$base_body" + fi + if ! curl -fsS --max-time 8 https://api.osv.dev/v1/query \ + -H 'content-type: application/json' \ + -d "$body" \ + > "$output.raw"; then + printf '{"status":"error","note":"OSV query failed","vulns":[]}\n' > "$output" + return 0 + fi + # A page must be an object; vulns (when present) an array; and + # next_page_token (when present) a non-empty string — any other token + # shape (false, numbers, objects) is a malformed page that must not + # read as "complete with zero advisories". + if ! jq -e ' + (type == "object") + and ((has("vulns") | not) or (.vulns | type == "array")) + and ((has("next_page_token") | not) + or (.next_page_token + | (type == "string") and (length > 0) + # Tokens travel through shell command substitution, which is + # lossy for whitespace/control characters (a "\n" token would + # strip to empty and read as end-of-pagination): only accept + # tokens that survive the trip intact. + and (test("^[[:graph:]]+$")))) + ' "$output.raw" >/dev/null 2>&1; then + printf '{"status":"error","note":"OSV response malformed","vulns":[]}\n' > "$output" + return 0 + fi + if ! vulns=$(jq -cn --argjson acc "$vulns" --slurpfile r "$output.raw" '$acc + (($r[0].vulns) // [])' 2>/dev/null); then + printf '{"status":"error","note":"OSV response merge failed","vulns":[]}\n' > "$output" + return 0 + fi + local next_token + next_token=$(jq -r '.next_page_token // empty' "$output.raw" 2>/dev/null || true) + [[ -z "$next_token" ]] && break + case " ${seen_tokens:-} " in + *" $next_token "*) + printf '{"status":"error","note":"OSV pagination token repeated","vulns":[]}\n' > "$output" + return 0 + ;; + esac + seen_tokens="${seen_tokens:-} $next_token" + page_token="$next_token" + done + jq -cn --argjson v "$vulns" '{status: "ok", note: null, vulns: $v}' > "$output" +} + +# --------------------------------------------------------------------------- +# Target-version resolution and version-aware advisory matching. +# +# `safe audit check` used to post version:"latest" to OSV, which cannot parse +# it and returns every advisory ever filed for the package — so the verdict +# was a version-blind count that WARN-blocked the very bumps remediating a +# CVE. The resolver below determines the version the package manager would +# actually install; advisories are then matched per-version against their +# affected ranges. When resolution fails the check degrades to a package-only +# query with a WARN floor (reason: version_unresolved) — never a silent GO. +# --------------------------------------------------------------------------- + +SAFE_RUN_CONFIG_FILE="$SAFE_RUN_CONFIG_DIR/config.json" +SAFE_RUN_HOST_ALLOW_FILE="$SAFE_RUN_CONFIG_DIR/host-allow.json" +INSTALL_KNOWN_FILE="$SAFE_RUN_CONFIG_DIR/install-known.json" + +# Shared jq prelude: semver-ish comparison usable for npm/PyPI/crates/go +# release versions. Prerelease versions sort before their release +# ([x,y,z,0,"pre"] < [x,y,z,1,""]); build metadata is ignored. +JQ_VSEMVER=' +def vparse: + tostring + | sub("^[vV]"; "") + | split("+")[0] + | split("-") as $p + # Core segments convert only on the strict numeric grammar; anything else + # maps to -1 so a malformed core ("0e0.0.0") can never outrank a valid + # version — jq tonumber would read "0e0" as numeric 0 (delta-3 finding 4). + # Selection paths additionally validate their candidates upfront + # (npm_max_satisfying, resolve_target_versions); a hard error here instead + # would fail closed on every annotation over PEP440-style OSV event + # versions ("1.0.post1"), which are annotation-only and server-authoritative. + | ((($p[0] | split(".") | map(if test("^(0|[1-9][0-9]*)$") then tonumber else -1 end)) + [0, 0, 0])[0:3]) + + [if ($p | length) > 1 then 0 else 1 end] + + [if ($p | length) > 1 + # SemVer prerelease precedence is identifier-by-identifier: numeric + # identifiers compare numerically and rank below alphanumeric ones, + # and a shorter identifier list ranks below its extension. jq array + # comparison over (number|string) elements matches all three rules + # (numbers sort before strings; arrays compare element-wise with + # shorter-prefix-first) — a single lexical string did not + # (alpha.10 < alpha.2, PR#29 delta finding 4). Convert ONLY on the + # SemVer numeric grammar (0|[1-9][0-9]*): jq numbers are broader, so + # tonumber? would type "1e-2" numeric while npm treats it as + # alphanumeric (delta-2 finding 4). + then ($p[1:] | join("-") | split(".") + | map(if test("^(0|[1-9][0-9]*)$") then tonumber else . end)) + else [] + end]; +def vlt($a; $b): ($a | vparse) < ($b | vparse); +def vle($a; $b): ($a | vparse) <= ($b | vparse); +def vge($a; $b): ($a | vparse) >= ($b | vparse); +def vgt($a; $b): ($a | vparse) > ($b | vparse); +def veq($a; $b): ($a | vparse) == ($b | vparse); +' + +install_policy_json() { + jq -c '.install // {}' "$SAFE_RUN_CONFIG_FILE" 2>/dev/null || printf '{}' +} + +# A source is trusted when it is the ecosystem's default public registry or +# the operator listed it in install.trusted_registries. Anything else changes +# artifact identity: public advisory data cannot vouch for a private artifact +# of the same name@version. +registry_is_trusted() { + local url="${1%/}" ecosystem="$2" + # Origin-boundary matching only: a glob like pypi.org* would trust + # pypi.org.evil.example and https://pypi.org@evil.example (userinfo trick). + case "${ecosystem}:${url}" in + npm:https://registry.npmjs.org) return 0 ;; + python:https://pypi.org|python:https://pypi.org/*) return 0 ;; + esac + local trusted + while IFS= read -r trusted; do + [[ -n "$trusted" ]] || continue + trusted="${trusted%/}" + if [[ "$url" == "$trusted" || "$url" == "$trusted"/* ]]; then + return 0 + fi + done < <(install_policy_json | jq -r '(.trusted_registries // [])[]' 2>/dev/null || true) + return 1 +} + +# Full SemVer 2.0.0 grammar for the semver ecosystems: no leading zeros in +# core or numeric prerelease identifiers, no empty dot-separated identifiers +# — npm/cargo/composer selectors reject those outright, so accepting them +# here would audit a version the manager never installs (delta-4 finding 4). +SEMVER_STRICT_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + +# node-semver refuses versions longer than 256 characters and numeric core +# components above Number.MAX_SAFE_INTEGER — npm classifies anything beyond +# as a dist-TAG, so accepting it as a version here audits a string npm +# resolves to a different artifact (delta-5 finding 4). npm-only: other +# ecosystems' parsers have different limits. +npm_semver_within_limits() { + local version="$1" + (( ${#version} <= 256 )) || return 1 + local core="${version%%[-+]*}" c + local IFS='.' + for c in $core; do + if (( ${#c} > 16 )) || { (( ${#c} == 16 )) && [[ "$c" > "9007199254740991" ]]; }; then + return 1 + fi + done + return 0 +} + +version_is_exact() { + local ecosystem="$1" version="$2" + case "$ecosystem" in + npm) + # node-semver measures its 256-char ceiling on the RAW input, before + # the v prefix is stripped (delta-6 finding 4). + (( ${#version} <= 256 )) \ + && [[ "${version#v}" =~ $SEMVER_STRICT_RE ]] \ + && npm_semver_within_limits "${version#v}" + ;; + rust|php) + [[ "${version#v}" =~ $SEMVER_STRICT_RE ]] + ;; + python) + [[ "$version" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$ ]] + ;; + go) + [[ "$version" == v* && "${version#v}" =~ $SEMVER_STRICT_RE ]] + ;; + *) + [[ "$version" =~ ^v?[0-9]+(\.[0-9]+)+$ ]] + ;; + esac +} + +strip_trailing_slashes() { + local v="$1" + while [[ "$v" == */ ]]; do v="${v%/}"; done + printf '%s' "$v" +} + +# Canonical source form for identities, receipts, trust matching, and every +# printed message: trailing slashes stripped, and for URLs the userinfo, +# query string, and fragment dropped — index URLs routinely carry inline +# credentials (userinfo or ?token= query values) and those must never reach +# refusal output, install-known.json, or config.json trust entries (delta-5 +# finding N2; delta-6 extended to query/fragment). Operational fetch URLs +# keep their credentials — ONLY identities and sinks go through here +# (delta-6: the wrappers no longer pre-redact, which broke authenticated +# packument fetches). A scoped selector token (@scope:registry=URL) keeps +# its key and canonicalizes its URL part. +canonicalize_source() { + local v="$1" scoped_prefix="" + if [[ "$v" == @*:registry=* ]]; then + scoped_prefix="${v%%=*}=" + v="${v#*=}" + fi + if [[ "$v" == *://* ]]; then + v="${v%%\#*}" + v="${v%%\?*}" + if [[ "$v" =~ ^([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@]+@)(.+)$ ]]; then + v="${BASH_REMATCH[1]}${BASH_REMATCH[3]}" + fi + fi + v=$(strip_trailing_slashes "$v") + printf '%s%s' "$scoped_prefix" "$v" +} + +# The URL half of a source element: scoped selector tokens carry their key +# inline; trust decisions apply to the endpoint itself. +source_element_url() { + local v="$1" + [[ "$v" == @*:registry=* ]] && v="${v#*=}" + printf '%s' "$v" +} + +# Canonicalize a space-separated source set: strip trailing slashes per +# element and keep the LAST occurrence of a repeated element — npm options +# are last-wins, so ordering carries meaning for resolution while the trust +# sweep stays order-independent (delta-4 findings 3.1 and N1: writer and +# readers must derive byte-identical identities for the same invocation). +normalize_source_set() { + local out="" w x kept + for w in $1; do + w=$(canonicalize_source "$w") + [[ -n "$w" ]] || continue + kept="" + for x in $out; do + [[ "$x" == "$w" ]] || kept="${kept:+$kept }$x" + done + out="${kept:+$kept }$w" + done + printf '%s' "$out" +} + +# The user-level npm config file: an argv --userconfig (threaded as a PATH +# via --npm-userconfig, never flattened into values — delta-7 finding 3.2b) +# wins, then the env forms (lowercase first, npm's own env precedence), +# then the default. The global tier is analogous, with no default file +# modeled (npm's builtin /usr/etc/npmrc is operator-owned machine state). +# Config paths anchor a leading dash to ./ — a file literally named "-q" +# is a valid config target to npm but reads as an OPTION to grep/awk, +# silently yielding no sources (fail-open; delta-8 finding 3.2b). +anchor_path() { + local p="$1" + [[ "$p" == -* ]] && p="./$p" + printf '%s' "$p" +} + +npm_user_rc() { + if [[ -n "${CHECK_NPM_USERCONFIG:-}" ]]; then + anchor_path "$CHECK_NPM_USERCONFIG" + elif [[ -n "${npm_config_userconfig:-}" ]]; then + anchor_path "$npm_config_userconfig" + elif [[ -n "${NPM_CONFIG_USERCONFIG:-}" ]]; then + anchor_path "$NPM_CONFIG_USERCONFIG" + else + printf '%s' "$HOME/.npmrc" + fi +} + +npm_global_rc() { + if [[ -n "${CHECK_NPM_GLOBALCONFIG:-}" ]]; then + anchor_path "$CHECK_NPM_GLOBALCONFIG" + elif [[ -n "${npm_config_globalconfig:-}" ]]; then + anchor_path "$npm_config_globalconfig" + elif [[ -n "${NPM_CONFIG_GLOBALCONFIG:-}" ]]; then + anchor_path "$NPM_CONFIG_GLOBALCONFIG" + fi +} + +npm_registry_url() { + local name="${1:-${PKG_NAME:-}}" + # A scope-specific registry beats every generic source INCLUDING a + # generic --registry: npm resolves the @scope:registry config KEY, which + # a generic selector never sets (delta-4 finding 3.2). Key precedence + # follows npm: CLI scoped selectors (threaded as @scope:registry=URL + # tokens in CHECK_REGISTRY, LAST occurrence wins — delta-6 finding 3.2b), + # then env (lowercase first), then project rc, then the user rc (which + # --userconfig's env forms replace). + local scope="" + [[ "$name" == @*/* ]] && scope="${name%%/*}" + if [[ -n "$scope" ]]; then + local scoped_src="" src_word + for src_word in ${CHECK_REGISTRY:-}; do + [[ "$src_word" == "${scope}:registry="* ]] && scoped_src="${src_word#*=}" + done + [[ -n "$scoped_src" ]] || scoped_src=$(printenv "npm_config_${scope}:registry" 2>/dev/null || true) + [[ -n "$scoped_src" ]] || scoped_src=$(printenv "NPM_CONFIG_${scope}:registry" 2>/dev/null || true) + if [[ -z "$scoped_src" ]]; then + local rc line scope_re="${scope//./\\.}" + for rc in "${CHECK_PROJECT_DIR:-.}/.npmrc" "$(npm_user_rc)" "$(npm_global_rc)"; do + [[ -n "$rc" && -f "$rc" ]] || continue + line=$(grep -E "^[[:space:]]*${scope_re}:registry[[:space:]]*=" "$rc" 2>/dev/null | tail -n 1 || true) + if [[ -n "$line" ]]; then + scoped_src=$(trim "${line#*=}") + break + fi + done + fi + if [[ -n "$scoped_src" ]]; then + strip_trailing_slashes "$scoped_src" + return 0 + fi + fi + # Generic --registry from the audited command line wins over env/rc: + # auditing the default registry while npm installs from another one audits + # the wrong artifact (PR#29 review finding 3). npm options are LAST-wins — + # with repeated --registry flags npm fetches from the final one (delta-3 + # finding 3.1); scoped tokens carry a different KEY and never participate + # in the generic selection. The custom-source floor still checks every + # seen selector. + if [[ -n "${CHECK_REGISTRY:-}" ]]; then + local last_src="" src_word + for src_word in ${CHECK_REGISTRY}; do + [[ "$src_word" == @*:registry=* ]] && continue + last_src="$src_word" + done + if [[ -n "$last_src" ]]; then + strip_trailing_slashes "$last_src" + return 0 + fi + fi + # npm reads its config environment case-insensitively and the LOWERCASE + # form wins when both are set (verified against npm's own resolution, + # delta-5 finding 3.2) — matching that order is load-bearing: auditing + # the uppercase value while npm fetches from the lowercase one audits + # the wrong artifact. + if [[ -n "${npm_config_registry:-}" ]]; then + strip_trailing_slashes "$npm_config_registry" return 0 fi - jq -n --slurpfile data "$output.raw" ' - ($data[0] // {}) as $r | + if [[ -n "${NPM_CONFIG_REGISTRY:-}" ]]; then + strip_trailing_slashes "$NPM_CONFIG_REGISTRY" + return 0 + fi + local rc line + for rc in "${CHECK_PROJECT_DIR:-.}/.npmrc" "$(npm_user_rc)" "$(npm_global_rc)"; do + [[ -n "$rc" && -f "$rc" ]] || continue + line=$(grep -E '^[[:space:]]*registry[[:space:]]*=' "$rc" 2>/dev/null | tail -n 1 || true) + if [[ -n "$line" ]]; then + printf '%s' "$(trim "${line#*=}")" | sed 's:/*$::' + return 0 + fi + done + printf 'https://registry.npmjs.org' +} + +# Recognized python source controls outside argv: pip/uv environment +# variables plus the pip config files pip itself merges (PIP_CONFIG_FILE, +# then venv, then user; index-url is last-wins per file precedence, +# extra-index-url accumulates). uv.toml / pyproject [tool.uv] index tables +# and system-level /etc pip.conf are NOT swept: TOML parsing on one side, +# operator-owned machine state on the other — argv + env + these files are +# the surfaces a hostile project or environment can silently inject +# (delta-4 finding 3.2). +# pip's boolean vocabulary, case-insensitive, one variable at a time — +# concatenating variables invented truth from "False"/"0 0" pairs (delta-6 +# finding N3: a false value must preserve default-source UX). +python_bool_truthy() { + local v + v=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + case "$v" in + 1|true|t|yes|y|on) return 0 ;; + esac + return 1 +} + +# Effective value lines for one key from a pip config file, with +# pip/configparser semantics (delta-6 finding 3.2c; delta-7 tightened): +# '=' and ':' delimiters, comments, indented continuation lines (multi-line +# find-links lists are idiomatic), option names normalized the way pip does +# (ASCII lowercase, '_' -> '-'), and SECTION precedence — [install] (the +# audited command's section) overrides [global] regardless of file order; +# within a section a later duplicate key wins. Other sections do not apply +# to installs and are ignored. +pip_config_values() { + local rc_file="$1" key="$2" + [[ -f "$rc_file" ]] || return 0 + awk -v key="$key" ' + # pip _normalize_name: ASCII lowercase, underscores to hyphens, and ONE + # leading -- removed ("--index-url =" is a valid pip config key, + # delta-8 finding 3.2c). + function norm(s) { s = tolower(s); gsub(/_/, "-", s); sub(/^--/, "", s); return s } + function trimmed(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s } + function indent_of(s) { match(s, /^[ \t]*/); return RLENGTH } + BEGIN { sect = ""; grab = 0; grabind = -1 } { - status: "ok", - note: null, - vulns: ($r.vulns // []) + raw = $0 + line = trimmed(raw) + if (line == "") next + # Full-line comments are dropped everywhere, indented or not, without + # ending a value block (ConfigParser semantics). + if (line ~ /^[#;]/) next + ind = indent_of(raw) + # ConfigParser: a line continues a value only when indented DEEPER + # than its option line; options themselves may sit at any base + # indent (delta-8 finding 3.2c — leading whitespace is not, by + # itself, a continuation marker). + if (grab && ind > grabind) { + vals[grabsect] = vals[grabsect] "\n" line + next + } + grab = 0 + if (line ~ /^\[/) { + sub(/^\[/, "", line); sub(/\].*$/, "", line) + sect = tolower(trimmed(line)) + next + } + p = index(line, "="); c = index(line, ":") + d = p + if (d == 0 || (c > 0 && c < p)) d = c + if (d == 0) next + k = substr(line, 1, d - 1); sub(/[ \t]+$/, "", k) + if (norm(k) == key && (sect == "global" || sect == "install")) { + grab = 1 + grabind = ind + grabsect = sect + vals[sect] = trimmed(substr(line, d + 1)) + } } - ' > "$output" + END { + out = ("install" in vals) ? vals["install"] : (("global" in vals) ? vals["global"] : "") + n = split(out, a, "\n") + for (i = 1; i <= n; i++) if (a[i] != "") print a[i] + } + ' "$rc_file" 2>/dev/null || true +} + +python_env_sources() { + local out="" v + for v in ${PIP_INDEX_URL:-} ${PIP_EXTRA_INDEX_URL:-} ${UV_DEFAULT_INDEX:-} ${UV_INDEX_URL:-} ${UV_INDEX:-} \ + ${PIP_FIND_LINKS:-} ${UV_FIND_LINKS:-} ${UV_EXTRA_INDEX_URL:-}; do + out="${out:+$out }$v" + done + # no-index flips resolution to find-links/local sources entirely; the + # sentinel mirrors the argv scanner's marker (delta-5 finding 3.2). + if python_bool_truthy "${PIP_NO_INDEX:-}" || python_bool_truthy "${UV_NO_INDEX:-}"; then + out="${out:+$out }local:no-index" + fi + # Config paths are iterated as a quoted array: word-splitting an injected + # PIP_CONFIG_FILE containing spaces silently missed the file entirely + # (delta-7 finding 3.2c). + local -a py_rc_files=() + [[ -n "${PIP_CONFIG_FILE:-}" ]] && py_rc_files+=("$(anchor_path "$PIP_CONFIG_FILE")") + [[ -n "${VIRTUAL_ENV:-}" ]] && py_rc_files+=("$(anchor_path "$VIRTUAL_ENV")/pip.conf") + py_rc_files+=("$HOME/.config/pip/pip.conf" "$HOME/.pip/pip.conf") + local rc val + if [[ -z "${PIP_INDEX_URL:-}" ]]; then + for rc in "${py_rc_files[@]}"; do + [[ -f "$rc" ]] || continue + val=$(pip_config_values "$rc" index-url | tail -n 1) + if [[ -n "$val" ]]; then + out="${out:+$out }$val" + break + fi + done + fi + for rc in "${py_rc_files[@]}"; do + [[ -f "$rc" ]] || continue + while IFS= read -r val; do + [[ -n "$val" ]] && out="${out:+$out }$val" + done < <(pip_config_values "$rc" extra-index-url; pip_config_values "$rc" find-links) + val=$(pip_config_values "$rc" no-index | tail -n 1) + if [[ -n "$val" ]] && python_bool_truthy "$val"; then + out="${out:+$out }local:no-index" + fi + done + printf '%s' "$out" +} + +# GOPROXY is a comma/pipe-separated fallback chain; anything that is not +# the public proxy or a keyword changes where modules come from. +go_env_sources() { + local out="" part proxy="${GOPROXY:-}" + for part in ${proxy//[,|]/ }; do + case "$part" in + https://proxy.golang.org|https://proxy.golang.org/|direct|off) ;; + *) out="${out:+$out }$part" ;; + esac + done + printf '%s' "$out" +} + +# Effective sources for this check: the normalized argv set plus manager +# sources that act outside argv (env, rc files). Computed on EVERY check — +# exact-pin resolution shortcuts must not skip the trust floor (delta-3/4 +# finding 3.2). This is the single derivation behind the trust floor, the +# receipt writer, and (via the effective-sources plumbing command) every +# stale-evidence reader; sweeping surfaces we cannot represent (cargo +# config.toml registries, composer.json repositories declared by the +# project itself, yarn/bun native config) stays out by design — those +# either require argv selectors we already scan or are operator-committed +# project architecture, not injectable state. +derive_effective_sources() { + local ecosystem="$1" name="$2" + local extra="" + CHECK_EFFECTIVE_SOURCES=$(normalize_source_set "${CHECK_REGISTRY:-}") + case "$ecosystem" in + npm) + # The effective endpoint for THIS package, resolved with npm's config + # tiers (argv/env-selected alternate config files included via + # npm_user_rc/npm_global_rc). All-keys sweeps of alternate files were + # both wrong-precedence and over-flooring (delta-7 finding 3.2b). + extra=$(npm_registry_url "$name") + ;; + python) extra=$(python_env_sources) ;; + go) extra=$(go_env_sources) ;; + esac + local w existing seen + for w in $extra; do + w=$(canonicalize_source "$w") + [[ -n "$w" ]] || continue + # The ecosystem default never makes an identity explicit. + case "${ecosystem}:${w}" in + npm:https://registry.npmjs.org) continue ;; + python:https://pypi.org|python:https://pypi.org/simple) continue ;; + esac + # Membership is endpoint-level: a scoped token already carrying this + # URL must not gain a redundant bare twin (the effective npm source of + # a scoped package IS that token's URL). + seen=0 + for existing in ${CHECK_EFFECTIVE_SOURCES}; do + if [[ "$(source_element_url "$existing")" == "$(source_element_url "$w")" ]]; then + seen=1 + break + fi + done + (( seen )) || CHECK_EFFECTIVE_SOURCES="${CHECK_EFFECTIVE_SOURCES:+${CHECK_EFFECTIVE_SOURCES} }${w}" + done +} + +source_identity() { + if [[ -n "${CHECK_EFFECTIVE_SOURCES:-}" ]]; then + printf 'explicit:%s' "$CHECK_EFFECTIVE_SOURCES" + else + printf 'implicit-default' + fi +} + +# Desugar one npm range token (^1.2.3, ~1.2, >=1.0.0, 1.x, 1.2, 2, =1.0.0) +# into JSON [{op,v}] constraints. Fails (return 1) on anything it cannot +# faithfully express — the caller then degrades to the unresolved path. +npm_desugar_token() { + local t op v + t=$(trim "$1") + case "$t" in + ">="*) op="ge"; v="${t#>=}" ;; + "<="*) op="le"; v="${t#<=}" ;; + ">"*) op="gt"; v="${t#>}" ;; + "<"*) op="lt"; v="${t#<}" ;; + "="*) op="eq"; v="${t#=}" ;; + "^"*) op="caret"; v="${t:1}" ;; + "~"*) op="tilde"; v="${t:1}" ;; + *) op="plain"; v="$t" ;; + esac + v=$(trim "$v") + v="${v#v}" + local maj min pat rest + IFS='.' read -r maj min pat rest <<<"$v" + [[ -n "$rest" ]] && return 1 + local pat_num="${pat%%[-+]*}" + # wild = index of first wildcard/missing segment (3 = fully specified) + local wild=3 + if [[ -z "$min" || "$min" == "x" || "$min" == "X" || "$min" == "*" ]]; then + wild=1 + elif [[ -z "$pat" || "$pat" == "x" || "$pat" == "X" || "$pat" == "*" ]]; then + wild=2 + fi + [[ "$maj" =~ ^[0-9]+$ ]] || return 1 + if (( wild == 3 )); then + [[ "$min" =~ ^[0-9]+$ && "$pat_num" =~ ^[0-9]+$ ]] || return 1 + elif (( wild == 2 )); then + [[ "$min" =~ ^[0-9]+$ ]] || return 1 + fi + local lo hi + case "$op" in + plain|eq) + case "$wild" in + 3) jq -cn --arg v "$v" '[{op:"eq", v:$v}]'; return 0 ;; + 2) lo="$maj.$min.0"; hi="$maj.$((min + 1)).0" ;; + 1) lo="$maj.0.0"; hi="$((maj + 1)).0.0" ;; + esac + ;; + ge) + case "$wild" in + 3) jq -cn --arg v "$v" '[{op:"ge", v:$v}]'; return 0 ;; + 2) jq -cn --arg v "$maj.$min.0" '[{op:"ge", v:$v}]'; return 0 ;; + 1) jq -cn --arg v "$maj.0.0" '[{op:"ge", v:$v}]'; return 0 ;; + esac + ;; + gt) + case "$wild" in + 3) jq -cn --arg v "$v" '[{op:"gt", v:$v}]'; return 0 ;; + 2) jq -cn --arg v "$maj.$((min + 1)).0" '[{op:"ge", v:$v}]'; return 0 ;; + 1) jq -cn --arg v "$((maj + 1)).0.0" '[{op:"ge", v:$v}]'; return 0 ;; + esac + ;; + le) + case "$wild" in + 3) jq -cn --arg v "$v" '[{op:"le", v:$v}]'; return 0 ;; + 2) jq -cn --arg v "$maj.$((min + 1)).0" '[{op:"lt", v:$v}]'; return 0 ;; + 1) jq -cn --arg v "$((maj + 1)).0.0" '[{op:"lt", v:$v}]'; return 0 ;; + esac + ;; + lt) + case "$wild" in + 3) jq -cn --arg v "$v" '[{op:"lt", v:$v}]'; return 0 ;; + 2) jq -cn --arg v "$maj.$min.0" '[{op:"lt", v:$v}]'; return 0 ;; + 1) jq -cn --arg v "$maj.0.0" '[{op:"lt", v:$v}]'; return 0 ;; + esac + ;; + caret) + case "$wild" in + 3) + lo="$v" + if (( maj > 0 )); then + hi="$((maj + 1)).0.0" + elif (( min > 0 )); then + hi="0.$((min + 1)).0" + else + hi="0.0.$((pat_num + 1))" + fi + ;; + 2) + lo="$maj.$min.0" + if (( maj > 0 )); then hi="$((maj + 1)).0.0"; else hi="0.$((min + 1)).0"; fi + ;; + 1) lo="$maj.0.0"; hi="$((maj + 1)).0.0" ;; + esac + ;; + tilde) + case "$wild" in + 3) lo="$v"; hi="$maj.$((min + 1)).0" ;; + 2) lo="$maj.$min.0"; hi="$maj.$((min + 1)).0" ;; + 1) lo="$maj.0.0"; hi="$((maj + 1)).0.0" ;; + esac + ;; + esac + jq -cn --arg lo "$lo" --arg hi "$hi" '[{op:"ge", v:$lo}, {op:"lt", v:$hi}]' +} + +# Desugar a full npm range expression into a JSON constraint conjunction. +# `||` alternatives, git/file/workspace specs, and unknown syntax fail. +npm_desugar_range() { + local raw + raw=$(trim "$1") + [[ "$raw" == *"||"* ]] && return 1 + case "$raw" in + ""|\*|x|X|latest) printf '[]'; return 0 ;; + *:*|git+*|http://*|https://*|github:*|file:*|link:*|workspace:*) return 1 ;; + esac + if [[ "$raw" == *" - "* ]]; then + local lo_part hi_part lo_cons hi_cons + lo_part="${raw%% - *}" + hi_part="${raw#* - }" + lo_cons=$(npm_desugar_token ">=$lo_part") || return 1 + hi_cons=$(npm_desugar_token "<=$hi_part") || return 1 + jq -cn --argjson a "$lo_cons" --argjson b "$hi_cons" '$a + $b' + return 0 + fi + local token cons + # read -a splits without pathname expansion: `for token in $raw` would glob + # a range like `1.*` against files in the cwd (PR#29 review finding 4). + local -a tokens=() all=() + read -r -a tokens <<<"$raw" + for token in "${tokens[@]}"; do + cons=$(npm_desugar_token "$token") || return 1 + all+=("$cons") + done + (( ${#all[@]} > 0 )) || return 1 + printf '%s\n' "${all[@]}" | jq -cs 'add' +} + +# Version an npm range resolves to from a packument. npm's selector prefers +# the dist-tag latest version whenever it satisfies the range (npm-pick- +# manifest semantics), falling back to the max satisfying stable version — +# picking plain-max here would audit a version npm does not install +# (PR#29 review finding 4). +npm_max_satisfying() { + local packument="$1" constraints="$2" + jq -r --argjson cons "$constraints" " + $JQ_VSEMVER + # Packument keys and dist-tags are registry-writable data: only strict + # full-grammar SemVer participates in selection, matching npm's own + # selector, which rejects malformed cores (\"0e0.0.0\") AND malformed + # prereleases — leading-zero numeric identifiers (\"1.0.0-01\") and + # empty identifiers (\"1.0.0-alpha..1\") — outright (delta-4 finding 4). + # node-semver limits mirror npm_semver_within_limits: <=256 chars and + # numeric core components <= Number.MAX_SAFE_INTEGER (as strings — jq + # numbers lose precision exactly at that boundary; delta-5 finding 4). + def semver_ok: + (length <= 256) + and test(\"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)([-](0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)([.](0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?\$\") + and ((split(\"+\")[0] | split(\"-\")[0] | split(\".\")) + | all((length < 16) or ((length == 16) and (. <= \"9007199254740991\")))); + def satisfies(\$ver): + all(\$cons[]; + . as \$c | + if \$c.op == \"ge\" then vge(\$ver; \$c.v) + elif \$c.op == \"gt\" then vgt(\$ver; \$c.v) + elif \$c.op == \"le\" then vle(\$ver; \$c.v) + elif \$c.op == \"lt\" then vlt(\$ver; \$c.v) + elif \$c.op == \"eq\" then veq(\$ver; \$c.v) + else false + end); + (.\"dist-tags\".latest // \"\") as \$latest + | if (\$latest != \"\") and (\$latest | semver_ok) and ((.versions // {}) | has(\$latest)) and satisfies(\$latest) + then \$latest + else + [ (.versions // {}) | keys[] + | select(semver_ok) + | select(contains(\"-\") | not) + | select(satisfies(.)) ] + | sort_by(vparse) | last // empty + end + " "$packument" +} + +# Ranges constraining a package in the project: package.json declared deps +# plus dependent ranges recorded in package-lock.json / npm-shrinkwrap. +# The project dir defaults to the cwd but follows --project-dir/--prefix. +npm_project_ranges() { + local name="$1" dir="${CHECK_PROJECT_DIR:-.}" + { + if [[ -f "$dir/package.json" ]]; then + jq -r --arg n "$name" ' + [.dependencies, .devDependencies, .optionalDependencies, .peerDependencies] + | map(select(. != null) | .[$n] // empty) + | .[]' "$dir/package.json" 2>/dev/null || true + fi + local lockfile + for lockfile in "$dir/npm-shrinkwrap.json" "$dir/package-lock.json"; do + [[ -f "$lockfile" ]] || continue + jq -r --arg n "$name" ' + [ (.packages // {}) | to_entries[] + | (.value.dependencies[$n]?, .value.devDependencies[$n]?, .value.optionalDependencies[$n]?) + | select(. != null and type == "string") ] + | .[]' "$lockfile" 2>/dev/null || true + break + done + } | sort -u | grep -v '^$' || true +} + +# A root `overrides` entry mentioning the package can force npm to install a +# version (or a different package) our resolver cannot predict — degrade to +# the unresolved path instead of auditing a guess (PR#29 review finding 4). +npm_overrides_mention() { + local name="$1" dir="${CHECK_PROJECT_DIR:-.}" + [[ -f "$dir/package.json" ]] || return 1 + # Override keys can be version-qualified specs ("demo@^2.0.0" targets + # package demo); match on the parsed package name, not literal keys. + jq -e --arg n "$name" ' + (.overrides // {}) + | [.. | objects | keys[]] + | any(. == $n or startswith($n + "@")) + ' "$dir/package.json" >/dev/null 2>&1 +} + +# Resolve the versions the package manager would actually install. +# Sets RESOLVED_VERSIONS (unique, max 4), RESOLUTION_METHOD, RESOLUTION_SOURCE, +# RESOLUTION_RANGE. Returns 1 when resolution is not possible. +resolve_target_versions() { + local ecosystem="$1" name="$2" requested="$3" op="$4" scratch="$5" + RESOLVED_VERSIONS=() + RESOLUTION_METHOD="" + RESOLUTION_SOURCE="" + RESOLUTION_RANGE="" + if [[ -n "$requested" && "$requested" != "latest" ]] && version_is_exact "$ecosystem" "$requested"; then + RESOLVED_VERSIONS=("$requested") + RESOLUTION_METHOD="exact" + return 0 + fi + command -v curl >/dev/null 2>&1 || return 1 + case "$ecosystem" in + npm) resolve_npm_versions "$name" "$requested" "$op" "$scratch" || return 1 ;; + python) resolve_registry_latest "$requested" "https://pypi.org/pypi/${name}/json" '.info.version // empty' pypi.org "$scratch" || return 1 ;; + rust) resolve_registry_latest "$requested" "https://crates.io/api/v1/crates/${name}" '.crate.max_stable_version // .crate.newest_version // empty' crates.io "$scratch" || return 1 ;; + php) resolve_registry_latest "$requested" "https://repo.packagist.org/p2/${name}.json" --arg-name "$name" packagist.org "$scratch" || return 1 ;; + go) resolve_registry_latest "$requested" "https://proxy.golang.org/${name}/@latest" '.Version // empty' proxy.golang.org "$scratch" || return 1 ;; + *) return 1 ;; + esac + # Every registry-supplied target (dist-tag value, range-resolved candidate, + # registry latest) must satisfy the same version grammar an exact pin does: + # this data is registry-writable, and a malformed selected target means we + # no longer model the manager's selector — degrade (delta-3 finding 4). + local rv + for rv in ${RESOLVED_VERSIONS[@]+"${RESOLVED_VERSIONS[@]}"}; do + version_is_exact "$ecosystem" "$rv" || return 1 + done + return 0 +} + +resolve_registry_latest() { + local requested="$1" url="$2" filter="$3" source_label="$4" scratch="$5" + local pkg_name="" + if [[ "$filter" == "--arg-name" ]]; then + pkg_name="$4" + source_label="$5" + scratch="$6" + fi + # A custom index/registry on the command line means this default-registry + # lookup would audit the wrong source — degrade to the unresolved path. + [[ -z "${CHECK_REGISTRY:-}" ]] || return 1 + # Only an unpinned request can resolve to "latest"; ranges are unresolvable + # here in v1 and degrade to the package-level audit. + [[ -z "$requested" || "$requested" == "latest" ]] || return 1 + local out="$scratch/resolve.json" + curl -fsS --max-time 5 "$url" -o "$out" 2>/dev/null || return 1 + local version + if [[ -n "$pkg_name" ]]; then + version=$(jq -r --arg n "$pkg_name" '.packages[$n][0].version // empty' "$out" 2>/dev/null || true) + version="${version#v}" + else + version=$(jq -r "$filter" "$out" 2>/dev/null || true) + fi + [[ -n "$version" ]] || return 1 + RESOLVED_VERSIONS=("$version") + RESOLUTION_METHOD="registry-latest" + RESOLUTION_SOURCE="$source_label" + return 0 +} + +resolve_npm_versions() { + local name="$1" requested="$2" op="$3" scratch="$4" + local registry packument enc_name + # Root overrides can force npm to a version (or package) this resolver + # cannot predict: degrade rather than audit a guess. + if npm_overrides_mention "$name"; then + return 1 + fi + registry=$(npm_registry_url "$name") + packument="$scratch/packument.json" + enc_name="${name//\//%2F}" + curl -fsS --max-time 5 -H 'Accept: application/vnd.npm.install-v1+json' \ + "$registry/$enc_name" -o "$packument" 2>/dev/null || return 1 + # The fetch keeps any inline credentials; the receipt/display field never + # does (delta-5 finding N2). + RESOLUTION_SOURCE=$(canonicalize_source "$registry") + local -a ranges=() + if [[ -n "$requested" && "$requested" != "latest" ]]; then + local tag_version + tag_version=$(jq -r --arg t "$requested" '."dist-tags"[$t] // empty' "$packument" 2>/dev/null || true) + if [[ -n "$tag_version" ]]; then + RESOLVED_VERSIONS=("$tag_version") + RESOLUTION_METHOD="dist-tag" + RESOLUTION_RANGE="$requested" + return 0 + fi + ranges=("$requested") + RESOLUTION_METHOD="range" + elif [[ "$op" == "update" ]]; then + local range + while IFS= read -r range; do + [[ -n "$range" ]] && ranges+=("$range") + done < <(npm_project_ranges "$name") + if (( ${#ranges[@]} == 0 )); then + # Not declared in the project: `npm update ` is then a no-op or + # behaves like an install; audit the dist-tag as the outer bound. + RESOLUTION_METHOD="dist-tag" + else + RESOLUTION_METHOD="project-range" + fi + else + RESOLUTION_METHOD="dist-tag" + fi + if [[ "$RESOLUTION_METHOD" == "dist-tag" ]]; then + # An explicit --tag on the command line changes what an unversioned + # install targets; audit that tag, not latest. + local tag_name="${CHECK_DIST_TAG:-latest}" latest_version + latest_version=$(jq -r --arg t "$tag_name" '."dist-tags"[$t] // empty' "$packument" 2>/dev/null || true) + [[ -n "$latest_version" ]] || return 1 + RESOLUTION_RANGE="$tag_name" + RESOLVED_VERSIONS=("$latest_version") + return 0 + fi + RESOLUTION_RANGE=$(printf '%s, ' "${ranges[@]}") + RESOLUTION_RANGE="${RESOLUTION_RANGE%, }" + local r cons v + local -a resolved=() + for r in "${ranges[@]}"; do + cons=$(npm_desugar_range "$r") || return 1 + v=$(npm_max_satisfying "$packument" "$cons" 2>/dev/null || true) + [[ -n "$v" ]] || return 1 + resolved+=("$v") + done + local seen="" + RESOLVED_VERSIONS=() + for v in "${resolved[@]}"; do + case " $seen " in + *" $v "*) continue ;; + esac + seen="$seen $v" + RESOLVED_VERSIONS+=("$v") + done + # A wall of distinct in-range targets means we no longer understand what + # the resolver will do; degrade instead of pretending. + (( ${#RESOLVED_VERSIONS[@]} <= 4 )) || return 1 + return 0 +} + +# Severity + classification jq program (single-quoted: no shell escaping). +# Severity ladder, most-severe candidate wins (fail-closed): the qualitative +# database_specific/ecosystem_specific labels AND any computable standard +# CVSS v3 base score are all candidates; a CVSS v4-only advisory with no +# other parsable severity floors at "high" (v4 scoring not implemented; the +# receipt carries the raw data). Nothing parsable -> "unknown". +# shellcheck disable=SC2016 +JQ_OSV_CLASSIFY=' +def band_rank(b): ({critical: 4, high: 3, moderate: 2, low: 1}[b]) // 0; +def norm_qual: + (tostring | ascii_downcase) + | (if . == "medium" then "moderate" else . end) + | select(. == "critical" or . == "high" or . == "moderate" or . == "low"); +def cvss3_metrics: split("/") | map(split(":") | select(length == 2) | {(.[0]): .[1]}) | add // {}; +def cvss3_band: + cvss3_metrics as $m + | ({N: 0.85, A: 0.62, L: 0.55, P: 0.2}[$m["AV"]?]) as $av + | ({L: 0.77, H: 0.44}[$m["AC"]?]) as $ac + | ({N: 0.85, R: 0.62}[$m["UI"]?]) as $ui + | ($m["S"]? == "C") as $chg + | ((if $chg then {N: 0.85, L: 0.68, H: 0.5} else {N: 0.85, L: 0.62, H: 0.27} end)[$m["PR"]?]) as $pr + | ({H: 0.56, L: 0.22, N: 0}[$m["C"]?]) as $c + | ({H: 0.56, L: 0.22, N: 0}[$m["I"]?]) as $i + | ({H: 0.56, L: 0.22, N: 0}[$m["A"]?]) as $a + | if ($av == null or $ac == null or $ui == null or $pr == null or $c == null or $i == null or $a == null) + then empty + else + (1 - ((1 - $c) * (1 - $i) * (1 - $a))) as $iss + | (if $chg then (7.52 * ($iss - 0.029)) - (3.25 * pow($iss - 0.02; 15)) else 6.42 * $iss end) as $impact + | (8.22 * $av * $ac * $pr * $ui) as $expl + | (if $impact <= 0 then 0 + else + (($impact + $expl) * (if $chg then 1.08 else 1 end)) as $sum + | (if $sum < 10 then $sum else 10 end) + end) as $rawscore + | ((($rawscore * 10) | ceil) / 10) as $score + | (if $score >= 9 then "critical" + elif $score >= 7 then "high" + elif $score >= 4 then "moderate" + elif $score > 0 then "low" + else empty + end) + end; +def std_sev_entries($aff): (.severity // []) + ([ $aff[].severity? // empty ] | add // []); +def vuln_sev($aff): + ( + [ .database_specific.severity? // empty | norm_qual ] + + [ std_sev_entries($aff)[] | select(.type == "CVSS_V3") | (.score // empty) | cvss3_band ] + + [ $aff[].ecosystem_specific.severity? // empty | norm_qual ] + ) as $cands + | ((std_sev_entries($aff) | map(select(.type == "CVSS_V4")) | length) > 0) as $has_v4 + | if ($cands | length) > 0 then ($cands | max_by(band_rank(.))) + elif $has_v4 then "high" + else "unknown" + end; +def matching_affected: [ .affected[]? | select((.package.ecosystem // "") == $eco and (.package.name // "") == $name) ]; +def usable_ranges: [ .[].ranges[]? | select(.type == "SEMVER" or .type == "ECOSYSTEM") | select((.events | length) > 0) ]; +def range_affected($r): + reduce ($r.events // [])[] as $e (false; + if ($e.introduced? != null) and vge($ver; $e.introduced) then true + elif ($e.fixed? != null) and vge($ver; $e.fixed) then false + elif ($e.last_affected? != null) and vgt($ver; $e.last_affected) then false + else . + end); +def range_fixed_events($r): [ ($r.events // [])[] | .fixed? | select(. != null) ]; +[ .vulns[]? + | matching_affected as $aff + | ($aff | usable_ranges) as $ranges + | ([ $aff[].versions[]? ]) as $version_list + | (($ranges | length) > 0 or ($version_list | length) > 0) as $parseable + | ( + ($version_list | index($ver) != null) + or ([ $ranges[] | range_affected(.) ] | any) + ) as $range_hit + | (if $trust == 1 then true else ($parseable and $range_hit) end) as $affects + | ([ $ranges[] | range_fixed_events(.)[] ] | sort_by(vparse) | last) as $fixed_max + | { + id: .id, + severity: vuln_sev($aff), + affects: $affects, + ambiguous: ($parseable | not), + unfixed: ($affects and (($fixed_max // "") == "")), + fixed: ($fixed_max // null), + remediated: (($trust == 0) and ($affects | not) and $parseable and $fixed_max != null and vle($fixed_max; $ver)) + } +] +| { + affecting: [ .[] | select(.affects) ], + remediated: [ .[] | select(.remediated) ], + ambiguous: [ .[] | select(.ambiguous) ], + unaffected: [ .[] | select((.affects or .remediated or .ambiguous) | not) ] + } +' + +# Classify OSV advisories against a resolved version. +# trust_server=1: the OSV query was version-scoped, so the server already +# matched every returned advisory against this exact version — each one is +# AUTHORITATIVELY affecting. Local range data is annotation only and can +# never downgrade a server hit (a local comparator disagreement was a +# GO-on-vulnerable bypass; PR#29 review finding 1). +# trust_server=0 (package-only query): local range matching applies; +# unparseable advisories stay ambiguous, never affecting. +osv_classify() { + local osv_file="$1" osv_eco="$2" name="$3" version="$4" trust_server="$5" + jq -c --arg eco "$osv_eco" --arg name "$name" --arg ver "$version" --argjson trust "$trust_server" \ + "$JQ_VSEMVER $JQ_OSV_CLASSIFY" "$osv_file" +} + +# A later adverse check supersedes recorded clean evidence: drop the entry +# when its pinned version is among the versions this check just judged +# (PR#29 review finding 5 — stale evidence must never outlive a newer +# WARN/BLOCK for the same version). +revoke_install_known() { + local ecosystem="$1" name="$2" + shift 2 + [[ -f "$INSTALL_KNOWN_FILE" ]] || return 0 + local key="$ecosystem:$name" entry_version tmp v + entry_version=$(jq -r --arg k "$key" '.packages[$k].version // empty' "$INSTALL_KNOWN_FILE" 2>/dev/null || true) + [[ -n "$entry_version" ]] || return 0 + for v in "$@"; do + if [[ "$v" == "$entry_version" ]]; then + tmp=$(mktemp) + jq --arg k "$key" 'del(.packages[$k])' "$INSTALL_KNOWN_FILE" > "$tmp" && mv "$tmp" "$INSTALL_KNOWN_FILE" + return 0 + fi + done + return 0 +} + +mark_install_known() { + local ecosystem="$1" name="$2" version="$3" verdict="$4" reasons_json="$5" evidence="$6" + local tmp key source_id + key="$ecosystem:$name" + # Evidence is scoped to the source it was gathered against: a receipt from + # the default registry must never satisfy a later custom-index request + # (delta-2 finding 5). Discriminated identity: a literal selector spelled + # "default" (cargo named registries) must never collide with the + # implicit-default sentinel (delta-3 finding 5). Legacy receipts with no + # source field map to implicit-default only. CHECK_EFFECTIVE_SOURCES was + # derived at the trust floor; the same identity is served to the stale + # readers by the effective-sources plumbing command (delta-4 finding N1). + source_id=$(source_identity) + mkdir -p "$SAFE_RUN_CONFIG_DIR" + [[ -f "$INSTALL_KNOWN_FILE" ]] || printf '{"packages":{}}\n' > "$INSTALL_KNOWN_FILE" + tmp=$(mktemp) + jq --arg k "$key" --arg v "$version" --arg verdict "$verdict" \ + --argjson reasons "$reasons_json" --arg ev "$evidence" \ + --arg src "$source_id" \ + --arg now "$(now_iso)" --arg d "$(today_date)" ' + (.packages[$k] // {}) as $old | + .packages[$k] = { + version: $v, + verdict: $verdict, + reasons: $reasons, + evidence: $ev, + source: $src, + first_allowed: (if ($old.version // "") == $v then ($old.first_allowed // $now) else $now end), + last_used: $d, + times_used: (if ($old.version // "") == $v then (($old.times_used // 0) + 1) else 1 end) + }' "$INSTALL_KNOWN_FILE" > "$tmp" && mv "$tmp" "$INSTALL_KNOWN_FILE" +} + +# Operator-pinned host-allow entry matching one of the resolved versions. +host_allow_matches_resolved() { + local ecosystem="$1" name="$2" + shift 2 + [[ "$ecosystem" == "npm" ]] || return 1 + [[ -f "$SAFE_RUN_HOST_ALLOW_FILE" ]] || return 1 + local entry_version entry_ecosystem + entry_version=$(jq -r --arg p "$name" '.packages[$p].version // empty' "$SAFE_RUN_HOST_ALLOW_FILE" 2>/dev/null || true) + entry_ecosystem=$(jq -r --arg p "$name" '.packages[$p].ecosystem // "npm"' "$SAFE_RUN_HOST_ALLOW_FILE" 2>/dev/null || true) + [[ -n "$entry_version" && "$entry_ecosystem" == "npm" ]] || return 1 + local v + for v in "$@"; do + [[ "$v" == "$entry_version" ]] && { HOST_ALLOW_RESOLVED_VERSION="$entry_version"; return 0; } + done + return 1 +} + +# Plumbing for the stale-evidence readers (install wrappers, bin/safe, +# gate-lib): print the discriminated source identity this invocation would +# audit under. Local-only — env + rc files, no network. Keeping the readers +# on this single derivation is what prevents writer/reader identity drift +# (delta-4 findings 3.2 and N1). +effective_sources_command() { + local ecosystem="" spec="" + local CHECK_DIST_TAG="" CHECK_REGISTRY="" CHECK_PROJECT_DIR="" + local CHECK_NPM_USERCONFIG="" CHECK_NPM_GLOBALCONFIG="" + local CHECK_EFFECTIVE_SOURCES="" + while [[ $# -gt 0 ]]; do + case "$1" in + --ecosystem) + [[ $# -lt 2 ]] && die "--ecosystem requires a value" + ecosystem="$2" + shift 2 + ;; + --npm-userconfig) + [[ $# -lt 2 ]] && die "--npm-userconfig requires a value" + CHECK_NPM_USERCONFIG="$2" + [[ "$CHECK_NPM_USERCONFIG" == -* ]] && CHECK_NPM_USERCONFIG="./${CHECK_NPM_USERCONFIG}" + shift 2 + ;; + --npm-globalconfig) + [[ $# -lt 2 ]] && die "--npm-globalconfig requires a value" + CHECK_NPM_GLOBALCONFIG="$2" + [[ "$CHECK_NPM_GLOBALCONFIG" == -* ]] && CHECK_NPM_GLOBALCONFIG="./${CHECK_NPM_GLOBALCONFIG}" + shift 2 + ;; + --registry) + [[ $# -lt 2 ]] && die "--registry requires a value" + CHECK_REGISTRY="$2" + shift 2 + ;; + --project-dir) + [[ $# -lt 2 ]] && die "--project-dir requires a value" + CHECK_PROJECT_DIR="$2" + [[ "$CHECK_PROJECT_DIR" == -* ]] && CHECK_PROJECT_DIR="./${CHECK_PROJECT_DIR}" + shift 2 + ;; + -*) + die "unknown effective-sources flag: $1" + ;; + *) + spec="$1" + shift + ;; + esac + done + [[ -z "$spec" ]] && die "usage: safe audit effective-sources [--ecosystem ] [--registry ] [--project-dir ]" + parse_pkg_spec "$spec" + [[ -z "$ecosystem" ]] && ecosystem=$(guess_ecosystem_from_spec "$spec") + case "$ecosystem" in + cargo) ecosystem="rust" ;; + composer) ecosystem="php" ;; + pip|pypi|uv) ecosystem="python" ;; + esac + derive_effective_sources "$ecosystem" "$PKG_NAME" + source_identity + printf '\n' } check_command() { - local ecosystem="" json_mode=0 spec="" + local ecosystem="" json_mode=0 spec="" op="install" gate="" + # Target-context selectors from the audited command line (dynamically + # scoped: the resolver helpers read them). CHECK_EFFECTIVE_SOURCES is the + # argv set plus any env/npmrc-configured npm source, computed at the trust + # floor; the receipt writer derives evidence identity from it. + local CHECK_DIST_TAG="" CHECK_REGISTRY="" CHECK_PROJECT_DIR="" + local CHECK_NPM_USERCONFIG="" CHECK_NPM_GLOBALCONFIG="" + local CHECK_EFFECTIVE_SOURCES="" while [[ $# -gt 0 ]]; do case "$1" in --ecosystem) @@ -3647,10 +4811,50 @@ check_command() { ecosystem="$2" shift 2 ;; + --npm-userconfig) + [[ $# -lt 2 ]] && die "--npm-userconfig requires a value" + CHECK_NPM_USERCONFIG="$2" + [[ "$CHECK_NPM_USERCONFIG" == -* ]] && CHECK_NPM_USERCONFIG="./${CHECK_NPM_USERCONFIG}" + shift 2 + ;; + --npm-globalconfig) + [[ $# -lt 2 ]] && die "--npm-globalconfig requires a value" + CHECK_NPM_GLOBALCONFIG="$2" + [[ "$CHECK_NPM_GLOBALCONFIG" == -* ]] && CHECK_NPM_GLOBALCONFIG="./${CHECK_NPM_GLOBALCONFIG}" + shift 2 + ;; --json) json_mode=1 shift ;; + --op) + [[ $# -lt 2 ]] && die "--op requires a value" + op="$2" + shift 2 + ;; + --gate) + [[ $# -lt 2 ]] && die "--gate requires a value" + gate="$2" + shift 2 + ;; + --dist-tag) + [[ $# -lt 2 ]] && die "--dist-tag requires a value" + CHECK_DIST_TAG="$2" + shift 2 + ;; + --registry) + [[ $# -lt 2 ]] && die "--registry requires a value" + CHECK_REGISTRY="$2" + shift 2 + ;; + --project-dir) + [[ $# -lt 2 ]] && die "--project-dir requires a value" + CHECK_PROJECT_DIR="$2" + # A path starting with '-' would be parsed as an option by the jq/ + # grep readers downstream; anchor it. + [[ "$CHECK_PROJECT_DIR" == -* ]] && CHECK_PROJECT_DIR="./${CHECK_PROJECT_DIR}" + shift 2 + ;; -*) die "unknown check flag: $1" ;; @@ -3660,23 +4864,56 @@ check_command() { ;; esac done - [[ -z "$spec" ]] && die "usage: safe audit check @ [--ecosystem ] [--json]" + [[ -z "$spec" ]] && die "usage: safe audit check [@] [--ecosystem ] [--op install|update] [--gate install] [--dist-tag ] [--registry ] [--project-dir ] [--json]" + [[ "$op" == "install" || "$op" == "update" ]] || die "--op must be install or update" + [[ -z "$gate" || "$gate" == "install" ]] || die "--gate supports only: install" parse_pkg_spec "$spec" [[ -z "$ecosystem" ]] && ecosystem=$(guess_ecosystem_from_spec "$spec") + # Public manager labels normalize to the internal resolver names; the + # wrappers send their tool-flavored ecosystem (cargo/composer/pip), which + # previously never reached the rust/php/python resolvers or valid OSV + # ecosystem names (PR#29 review finding 7). + case "$ecosystem" in + cargo) ecosystem="rust" ;; + composer) ecosystem="php" ;; + pip|pypi|uv) ecosystem="python" ;; + esac - local scratch socket_json osv_json block_reason verdict socket_line osv_line block_line socket_status osv_count blocked note_json + local scratch socket_json osv_json block_reason verdict socket_line osv_line block_line scratch=$(mktemp -d) socket_json="$scratch/socket.json" osv_json="$scratch/osv.json" - socket_score_json "$ecosystem" "$spec" "$socket_json" - osv_query_package_json "$ecosystem" "$PKG_NAME" "$PKG_VERSION" "$osv_json" + # --- resolve the version(s) the package manager would actually install + local resolution_ok=1 primary_version resolved_label="" + RESOLVED_VERSIONS=() + RESOLUTION_METHOD="" + RESOLUTION_SOURCE="" + RESOLUTION_RANGE="" + if ! resolve_target_versions "$ecosystem" "$PKG_NAME" "$PKG_VERSION" "$op" "$scratch"; then + resolution_ok=0 + fi + if (( resolution_ok )); then + primary_version="${RESOLVED_VERSIONS[0]}" + resolved_label=$(printf '%s, ' "${RESOLVED_VERSIONS[@]}") + resolved_label="${resolved_label%, }" + else + primary_version="$PKG_VERSION" + fi + + local socket_spec="$spec" + (( resolution_ok )) && socket_spec="${PKG_NAME}@${primary_version}" + socket_score_json "$ecosystem" "$socket_spec" "$socket_json" verdict="GO" + local -a warn_causes=() + local socket_detail="" socket_line="PASS (no behavioral anomalies)" if [[ "$(jq -r '.available // false' "$socket_json" 2>/dev/null)" != "true" ]]; then socket_line='SKIP (socket CLI not available)' + warn_causes+=("socket_unavailable") + socket_detail="socket CLI not available" verdict="WARN" elif [[ "$(jq -r '.status' "$socket_json")" != "ok" ]]; then local socket_note @@ -3684,69 +4921,219 @@ check_command() { case "$socket_note" in # 429 first: its message never mentions auth, and a bare *API* match # used to relabel any failure (even spinner text) as an auth problem - *"Too Many Requests"*|*" 429"*|*"rate limit"*) socket_line='WARN (socket rate-limited, retry later)' ;; - *Unauthorized*|*" 401"*|*token*|*Token*) socket_line='WARN (socket authentication required)' ;; - *) socket_line='WARN (socket check failed)' ;; + *"Too Many Requests"*|*" 429"*|*"rate limit"*) + socket_line='WARN (socket rate-limited, retry later)' + warn_causes+=("socket_rate_limited") + ;; + *Unauthorized*|*" 401"*|*token*|*Token*) + socket_line='WARN (socket authentication required)' + warn_causes+=("socket_auth_failed") + ;; + *) + socket_line='WARN (socket check failed)' + warn_causes+=("socket_error") + ;; esac + socket_detail="$socket_note" verdict="WARN" else local score_val score_val=$(jq -r '.raw.score // .raw.riskScore // empty' "$socket_json" 2>/dev/null || true) if [[ -n "$score_val" && "$score_val" =~ ^[0-9]+$ && "$score_val" -lt 70 ]]; then socket_line="WARN (score=$score_val)" + warn_causes+=("socket_low_score") verdict="WARN" fi fi - osv_count=$(jq '[.vulns[]?] | length' "$osv_json" 2>/dev/null || echo 0) - osv_line="PASS (${osv_count} known CVEs)" - if (( osv_count > 0 )); then - verdict="WARN" - osv_line="WARN (${osv_count} known CVEs)" + # --- version-aware OSV evaluation + local osv_eco + case "$ecosystem" in + npm) osv_eco="npm" ;; + python) osv_eco="PyPI" ;; + php) osv_eco="Packagist" ;; + rust) osv_eco="crates.io" ;; + go) osv_eco="Go" ;; + *) osv_eco="$ecosystem" ;; + esac + + local osv_status="ok" classifications='[]' combined='{"affecting":[],"remediated":[],"ambiguous":[],"unaffected":[]}' + local aff_count=0 rem_count=0 amb_count=0 total_count=0 osv_primary_file="" + if (( resolution_ok )); then + local v idx=0 cls vfile + for v in "${RESOLVED_VERSIONS[@]}"; do + vfile="$scratch/osv-$idx.json" + osv_query_package_json "$ecosystem" "$PKG_NAME" "$v" "$vfile" + [[ "$(jq -r '.status' "$vfile" 2>/dev/null)" == "ok" ]] || osv_status="error" + cls=$(osv_classify "$vfile" "$osv_eco" "$PKG_NAME" "$v" 1 2>/dev/null) || cls="" + if [[ -z "$cls" ]]; then + # Classification failure is advisory-data failure: fail closed. + osv_status="error" + cls='{"affecting":[],"remediated":[],"ambiguous":[],"unaffected":[]}' + fi + classifications=$(jq -cn --argjson acc "$classifications" --argjson c "$cls" --arg v "$v" '$acc + [{version: $v, classification: $c}]') + idx=$((idx + 1)) + done + osv_primary_file="$scratch/osv-0.json" + cp "$osv_primary_file" "$osv_json" + combined=$(jq -cn --argjson cs "$classifications" '{ + affecting: ([ $cs[].classification.affecting[] ] | unique_by(.id)), + remediated: ([ $cs[].classification.remediated[] ] | unique_by(.id)), + ambiguous: ([ $cs[].classification.ambiguous[] ] | unique_by(.id)), + unaffected: ([ $cs[].classification.unaffected[] ] | unique_by(.id)) + }') + aff_count=$(jq -r '.affecting | length' <<<"$combined") + rem_count=$(jq -r '.remediated | length' <<<"$combined") + amb_count=$(jq -r '.ambiguous | length' <<<"$combined") + total_count=$(jq -r '[.affecting, .remediated, .ambiguous, .unaffected] | map(length) | add' <<<"$combined") + else + osv_query_package_json "$ecosystem" "$PKG_NAME" "" "$osv_json" + [[ "$(jq -r '.status' "$osv_json" 2>/dev/null)" == "ok" ]] || osv_status="error" + total_count=$(jq '[.vulns[]?] | length' "$osv_json" 2>/dev/null || echo 0) + fi + + local block_severities affecting_blocked="" + block_severities=$(install_policy_json | jq -c '.block_severities // ["critical"]') + if (( resolution_ok )); then + if [[ "$osv_status" != "ok" ]]; then + # Previously an OSV outage silently counted as zero CVEs; fail closed. + osv_line="WARN (OSV query failed; advisory data unavailable)" + warn_causes+=("osv_unavailable") + bump_verdict verdict "WARN" + elif (( aff_count > 0 )); then + local aff_summary + aff_summary=$(jq -r '[.affecting[] | "\(.id) (\(.severity))"] | .[0:3] | join(", ")' <<<"$combined") + osv_line="WARN (${aff_count} of ${total_count} advisories affect ${resolved_label}: ${aff_summary})" + warn_causes+=("osv_affecting") + bump_verdict verdict "WARN" + affecting_blocked=$(jq -r --argjson block "$block_severities" '[.affecting[] | select(.severity as $s | $block | index($s))] | length' <<<"$combined") + if (( affecting_blocked > 0 )); then + osv_line="BLOCK (${aff_count} of ${total_count} advisories affect ${resolved_label}: ${aff_summary})" + bump_verdict verdict "BLOCK" + fi + elif (( total_count > 0 )); then + if (( rem_count > 0 )); then + osv_line="PASS (0 of ${total_count} advisories affect ${resolved_label}; ${rem_count} fixed at or below it)" + else + osv_line="PASS (0 of ${total_count} advisories affect ${resolved_label})" + fi + else + osv_line="PASS (no known advisories for ${resolved_label})" + fi + else + if [[ "$osv_status" != "ok" ]]; then + osv_line="WARN (version unresolved and OSV query failed)" + warn_causes+=("osv_unavailable") + else + osv_line="WARN (version unresolved; ${total_count} historical advisories for package)" + fi + warn_causes+=("version_unresolved") + bump_verdict verdict "WARN" if jq -e '.vulns[]? | select((.database_specific.severity // "") | ascii_downcase == "critical")' "$osv_json" >/dev/null 2>&1; then - verdict="BLOCK" + bump_verdict verdict "BLOCK" fi fi + # A custom source floors the verdict at WARN regardless of resolution or + # exactness: OSV/Socket evidence covers the public registry identity, not + # whatever a private index serves under that name@version (delta finding + # 3). CHECK_REGISTRY may carry MULTIPLE space-separated sources (pip/uv + # selectors are cumulative — a later trusted selector must never erase an + # earlier untrusted one, delta-2 finding 3): every source must be trusted. + # The EFFECTIVE source can also come from env/rc config with no argv + # selector at all (npm: NPM_CONFIG_REGISTRY/.npmrc incl. scoped keys; + # python: pip/uv env + pip.conf; go: GOPROXY), and exact pins skip + # resolution entirely — so it is derived here on every check (env + rc + # read only, no network; delta-3/4 finding 3.2). Operator escape hatches: + # install.trusted_registries, or a pinned host-allow. + local custom_source=0 check_src + derive_effective_sources "$ecosystem" "$PKG_NAME" + for check_src in ${CHECK_EFFECTIVE_SOURCES}; do + # Trust applies to the endpoint; a scoped token carries its key inline. + if ! registry_is_trusted "$(source_element_url "$check_src")" "$ecosystem"; then + custom_source=1 + warn_causes+=("custom_source") + bump_verdict verdict "WARN" + break + fi + done + block_line="PASS (not blocked)" if block_reason=$(blocked_entry_reason "$PKG_NAME"); then - verdict="BLOCK" + bump_verdict verdict "BLOCK" block_line="BLOCKED ($block_reason)" fi - local result_json - result_json="$CHECKS_DIR/$(today_date)-$(slugify "$PKG_NAME")-${PKG_VERSION}.json" + local warn_causes_json='[]' + if (( ${#warn_causes[@]} > 0 )); then + warn_causes_json=$(printf '%s\n' "${warn_causes[@]}" | jq -cR . | jq -cs .) + fi + + local result_json resolved_versions_json='[]' + if (( resolution_ok )); then + resolved_versions_json=$(printf '%s\n' "${RESOLVED_VERSIONS[@]}" | jq -cR . | jq -cs .) + fi + local version_slug="${primary_version//[^0-9A-Za-z._-]/-}" + result_json="$CHECKS_DIR/$(today_date)-$(slugify "$PKG_NAME")-${version_slug}.json" jq -n \ --arg timestamp "$(now_iso)" \ --arg spec "$spec" \ + --arg op "$op" \ --arg ecosystem "$ecosystem" \ --arg verdict "$verdict" \ --arg socket_line "$socket_line" \ --arg osv_line "$osv_line" \ --arg block_line "$block_line" \ + --arg resolution_method "$RESOLUTION_METHOD" \ + --arg resolution_source "$RESOLUTION_SOURCE" \ + --arg resolution_range "$RESOLUTION_RANGE" \ + --arg resolution_status "$( (( resolution_ok )) && printf 'ok' || printf 'unresolved')" \ + --argjson resolved_versions "$resolved_versions_json" \ + --argjson classification "$combined" \ + --argjson versions "$classifications" \ + --argjson warn_causes "$warn_causes_json" \ --slurpfile socket "$socket_json" \ --slurpfile osv "$osv_json" ' { timestamp: $timestamp, spec: $spec, + op: $op, ecosystem: $ecosystem, verdict: $verdict, + resolved_versions: $resolved_versions, + resolution: { + status: $resolution_status, + method: $resolution_method, + source: $resolution_source, + range: $resolution_range + }, + warn_causes: $warn_causes, checks: { socket: $socket_line, osv: $osv_line, blocklist: $block_line }, socket: ($socket[0] // {}), - osv: ($osv[0] // {}) + osv: (($osv[0] // {}) + {classification: $classification, versions: $versions}) }' > "$result_json" if [[ "$json_mode" == "1" ]]; then cat "$result_json" else printf '[safe audit] checking %s (%s)\n\n' "$spec" "$ecosystem" + if (( resolution_ok )); then + local resolution_extra="" + [[ -n "$RESOLUTION_RANGE" ]] && resolution_extra="; range ${RESOLUTION_RANGE}" + printf 'Resolved: %s (%s%s%s)\n' "$resolved_label" "$RESOLUTION_METHOD" "${RESOLUTION_SOURCE:+ via $RESOLUTION_SOURCE}" "$resolution_extra" + else + printf 'Resolved: (unresolved — auditing at package level)\n' + fi printf 'Socket: %s\n' "$socket_line" printf 'OSV: %s\n' "$osv_line" printf 'Blocklist: %s\n' "$block_line" + if (( custom_source )); then + printf '\n[safe audit] custom source: %s — advisory evidence covers the public registry identity only\n' "$CHECK_EFFECTIVE_SOURCES" + fi if [[ "$(jq -r '.available // false' "$socket_json" 2>/dev/null)" != "true" ]]; then printf '\n[safe audit] WARNING: socket CLI not available, skipping behavioral analysis\n' printf '[safe audit] Install: safe audit setup local\n' @@ -3754,6 +5141,69 @@ check_command() { printf '\nVERDICT: %s\n' "$verdict" fi + # --- install-gate mode: apply allow policy, emit actionable hints + if [[ "$gate" == "install" ]]; then + local policy auto_allow gate_exit=-1 + policy=$(install_policy_json) + auto_allow=$(jq -r 'if .auto_allow == false then "false" else "true" end' <<<"$policy") + case "$verdict" in + GO) + if [[ "$auto_allow" == "true" ]] && (( resolution_ok )); then + mark_install_known "$ecosystem" "$PKG_NAME" "$primary_version" "GO" \ + '["osv_clean_for_version","socket_ok","blocklist_pass"]' "$result_json" + printf '[safe audit] recorded clean check: %s:%s@%s (install-known)\n' \ + "$ecosystem" "$PKG_NAME" "$primary_version" >&2 + fi + gate_exit=0 + ;; + WARN) + # Adverse SECURITY knowledge (affecting advisory, blocklist hit, low + # Socket score) supersedes recorded clean evidence BEFORE any + # override decides this one invocation (delta finding 5). + # Enumerated pure-infrastructure WARNs (socket/OSV outage) do not + # revoke: the stale-evidence fallback exists precisely for those + # windows. + local adverse=0 adverse_cause + (( aff_count > 0 )) && adverse=1 + [[ "$block_line" != PASS* ]] && adverse=1 + for adverse_cause in ${warn_causes[@]+"${warn_causes[@]}"}; do + [[ "$adverse_cause" == "socket_low_score" ]] && adverse=1 + done + if (( adverse )); then + revoke_install_known "$ecosystem" "$PKG_NAME" "$primary_version" ${RESOLVED_VERSIONS[@]+"${RESOLVED_VERSIONS[@]}"} + fi + HOST_ALLOW_RESOLVED_VERSION="" + if (( resolution_ok )) && host_allow_matches_resolved "$ecosystem" "$PKG_NAME" "${RESOLVED_VERSIONS[@]}"; then + printf '[safe audit] host-allow entry %s@%s matches the resolved version; allowing\n' \ + "$PKG_NAME" "$HOST_ALLOW_RESOLVED_VERSION" >&2 + gate_exit=0 + elif [[ "$auto_allow" == "true" ]] && (( resolution_ok )) && (( aff_count == 0 )) \ + && [[ "$block_line" == PASS* ]] && (( ${#warn_causes[@]} > 0 )) \ + && [[ "$(jq -r --argjson causes "$warn_causes_json" '((.auto_allow_tolerate // []) | map(tostring)) as $tol | $causes | all(. as $c | $tol | index($c) != null)' <<<"$policy")" == "true" ]]; then + mark_install_known "$ecosystem" "$PKG_NAME" "$primary_version" "WARN_TOLERATED" \ + "$warn_causes_json" "$result_json" + printf '[safe audit] WARN causes tolerated by install.auto_allow_tolerate (%s); allowing %s@%s\n' \ + "$(printf '%s,' "${warn_causes[@]}" | sed 's/,$//')" "$PKG_NAME" "$primary_version" >&2 + gate_exit=0 + else + gate_emit_refusal_hints "$ecosystem" "$PKG_NAME" "$primary_version" "$resolution_ok" "$aff_count" "$socket_detail" "${warn_causes[@]}" + gate_exit=10 + fi + ;; + BLOCK) + printf 'safe audit: operator review required: safe audit check %s --ecosystem %s --json\n' \ + "$( (( resolution_ok )) && printf '%s@%s' "$PKG_NAME" "$primary_version" || printf '%s' "$spec")" "$ecosystem" >&2 + revoke_install_known "$ecosystem" "$PKG_NAME" "$primary_version" ${RESOLVED_VERSIONS[@]+"${RESOLVED_VERSIONS[@]}"} + gate_exit=20 + ;; + esac + if (( gate_exit >= 0 )); then + rm -rf "$scratch" + return "$gate_exit" + fi + fi + + rm -rf "$scratch" case "$verdict" in GO) return 0 ;; WARN) return 10 ;; @@ -3762,6 +5212,54 @@ check_command() { esac } +# Actionable refusal hints for install-gate mode, printed to stderr in cause +# priority order. Socket failures are infrastructure breakage — the wording +# must never read as a package vulnerability finding. +gate_emit_refusal_hints() { + local ecosystem="$1" name="$2" primary_version="$3" resolution_ok="$4" aff_count="$5" socket_detail="$6" + shift 6 + local -a causes=("$@") + local cause has_socket_infra=0 has_unresolved=0 has_affecting=0 has_osv_down=0 has_low_score=0 has_custom_source=0 + for cause in "${causes[@]}"; do + case "$cause" in + socket_unavailable|socket_auth_failed|socket_rate_limited|socket_error) has_socket_infra=1 ;; + version_unresolved) has_unresolved=1 ;; + osv_affecting) has_affecting=1 ;; + osv_unavailable) has_osv_down=1 ;; + socket_low_score) has_low_score=1 ;; + custom_source) has_custom_source=1 ;; + esac + done + if (( has_custom_source )); then + printf 'safe audit: custom package source — public advisory evidence cannot vouch for a private artifact\n' >&2 + printf 'safe audit: to trust this source permanently: add it to install.trusted_registries in ~/.config/safe/run/config.json (operator)\n' >&2 + fi + if (( has_affecting )); then + if [[ "$ecosystem" == "npm" ]]; then + printf 'safe audit: to allow this exact version: safe run host-allow add %s@%s --reason "..."\n' \ + "$name" "$primary_version" >&2 + else + printf 'safe audit: operator review: safe audit check %s@%s --ecosystem %s --json\n' \ + "$name" "$primary_version" "$ecosystem" >&2 + fi + fi + if (( has_socket_infra )); then + printf 'safe audit: socket score unavailable (%s) — infrastructure failure, NOT a package finding\n' \ + "${socket_detail:-unknown cause}" >&2 + printf 'safe audit: fix now: socket login (auth) / wait and retry (rate limit) / safe doctor (wiring); escalate to the operator if it persists\n' >&2 + fi + if (( has_unresolved )); then + printf 'safe audit: could not resolve the target version for %s — pin an exact version and retry\n' "$name" >&2 + printf 'safe audit: inspect: safe audit check %s@ --ecosystem %s\n' "$name" "$ecosystem" >&2 + fi + if (( has_osv_down )); then + printf 'safe audit: OSV advisory service unreachable — retry; if persistent, operator review\n' >&2 + fi + if (( has_low_score )); then + printf 'safe audit: socket behavioral score is low — operator review required (not auto-allowable)\n' >&2 + fi +} + release_github_command() { local repo="" version="" asset="" json_mode=0 while [[ $# -gt 0 ]]; do @@ -5908,6 +7406,11 @@ main() { capabilities_command "$@" return 0 ;; + effective-sources) + shift + effective_sources_command "$@" + return 0 + ;; esac if [[ "$SAFE_AUDIT_NO_INIT" != "1" ]]; then ensure_dirs diff --git a/bin/safe-run b/bin/safe-run index 2423093..c3d44ea 100755 --- a/bin/safe-run +++ b/bin/safe-run @@ -11,6 +11,7 @@ CONFIG_DIR="${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}" DATA_DIR="${SAFE_RUN_DATA_DIR:-${SAFE_DATA_DIR:-$HOME/.local/share/safe}/run}" HOST_ALLOW_FILE="$CONFIG_DIR/host-allow.json" SANDBOX_KNOWN_FILE="$CONFIG_DIR/sandbox-known.json" +INSTALL_KNOWN_FILE="$CONFIG_DIR/install-known.json" BLOCKED_FILE="$CONFIG_DIR/blocked.json" CONFIG_FILE="$CONFIG_DIR/config.json" AUDIT_LOG="$DATA_DIR/audit.log" @@ -190,6 +191,7 @@ ensure_dirs() { mkdir -p "$CONFIG_DIR" "$DATA_DIR" [[ -f "$HOST_ALLOW_FILE" ]] || seed_file "$HOST_ALLOW_FILE" '{"packages":{}}' [[ -f "$SANDBOX_KNOWN_FILE" ]] || seed_file "$SANDBOX_KNOWN_FILE" '{"packages":{}}' + [[ -f "$INSTALL_KNOWN_FILE" ]] || seed_file "$INSTALL_KNOWN_FILE" '{"packages":{}}' [[ -f "$BLOCKED_FILE" ]] || seed_file "$BLOCKED_FILE" "$(default_blocked_json)" [[ -f "$CONFIG_FILE" ]] || seed_file "$CONFIG_FILE" "$(default_config_json)" [[ -f "$AUDIT_LOG" ]] || : > "$AUDIT_LOG" @@ -231,7 +233,14 @@ default_config_json() { "timeout_seconds": 300 }, "warn_env_files": true, - "linked_targets": [] + "linked_targets": [], + "install": { + "auto_allow": true, + "auto_allow_tolerate": [], + "trusted_registries": [], + "auto_allow_ttl_days": 30, + "block_severities": ["critical"] + } } JSON } @@ -1576,9 +1585,10 @@ cmd_audit() { } cmd_status() { - local host_count sandbox_count blocked_count + local host_count sandbox_count install_count blocked_count host_count=$(jq '.packages | length' "$HOST_ALLOW_FILE") sandbox_count=$(jq '.packages | length' "$SANDBOX_KNOWN_FILE") + install_count=$(jq '.packages | length' "$INSTALL_KNOWN_FILE" 2>/dev/null || echo 0) blocked_count=$(jq '.packages | length' "$BLOCKED_FILE") echo "safe run $SAFE_RUN_VERSION" @@ -1587,6 +1597,7 @@ cmd_status() { echo printf "host-allow: %d (npm-only; bunx/uvx/pipx ignore these)\n" "$host_count" printf "sandbox-known: %d\n" "$sandbox_count" + printf "install-known: %d (recorded clean install checks)\n" "$install_count" printf "blocked: %d\n" "$blocked_count" echo if podman_available; then diff --git a/config/seed/run/config.json b/config/seed/run/config.json index ea84349..cbc1dc7 100644 --- a/config/seed/run/config.json +++ b/config/seed/run/config.json @@ -17,5 +17,12 @@ "timeout_seconds": 300 }, "warn_env_files": true, - "linked_targets": [] + "linked_targets": [], + "install": { + "auto_allow": true, + "auto_allow_tolerate": [], + "trusted_registries": [], + "auto_allow_ttl_days": 30, + "block_severities": ["critical"] + } } diff --git a/config/seed/run/install-known.json b/config/seed/run/install-known.json new file mode 100644 index 0000000..32737cf --- /dev/null +++ b/config/seed/run/install-known.json @@ -0,0 +1 @@ +{"packages":{}} diff --git a/docs/configuration.md b/docs/configuration.md index acde46d..dea2017 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,6 +8,7 @@ Unified config lives under: host-allow.json blocked.json sandbox-known.json + install-known.json config.json audit/ machines.json @@ -46,7 +47,40 @@ SAFE_CONFIG_DIR=/tmp/safe-config SAFE_DATA_DIR=/tmp/safe-data safe status `sandbox-known.json` stores packages accepted for future sandbox execution. -`config.json` stores runtime defaults, linked runner paths, sandbox limits, and warning behavior. +`install-known.json` is machine-written by `safe audit check --gate install`: +one entry per `ecosystem:name` recording the pinned resolved version that +passed a clean version-aware check, the reasons, and a pointer to the check +receipt. It smooths the install gate only (offline/timeout fallback within +`install.auto_allow_ttl_days`); no `safe run` exec path consults it. Operator +host-execution trust stays in `host-allow.json`, which is TTY-gated and +unchanged. + +`config.json` stores runtime defaults, linked runner paths, sandbox limits, warning behavior, and the install-gate policy: + +```json +{ + "install": { + "auto_allow": true, + "auto_allow_tolerate": [], + "trusted_registries": [], + "auto_allow_ttl_days": 30, + "block_severities": ["critical"] + } +} +``` + +- `auto_allow`: record clean checks in `install-known.json` (GO still + proceeds either way). +- `auto_allow_tolerate`: opt-in WARN causes (e.g. `socket_unavailable`) that + may proceed when no advisory affects the resolved version. Default: none. +- `trusted_registries`: package sources (URL prefixes) the operator trusts + like the default public registry. Any other custom source + (`--registry`, `--index-url`, `--find-links`, `--no-index`, + `--repository`) floors the verdict at WARN — public advisory data cannot + vouch for a private artifact of the same name@version. +- `auto_allow_ttl_days`: freshness window for the offline/timeout fallback. +- `block_severities`: affecting-advisory severities that hard-BLOCK instead + of WARN. Common sandbox settings include: diff --git a/docs/safe-audit.md b/docs/safe-audit.md index e4014ea..204f6ac 100644 --- a/docs/safe-audit.md +++ b/docs/safe-audit.md @@ -146,24 +146,74 @@ SAFE_AUDIT_GRYPE_DB_MAX_AGE_DAYS=14 safe audit scan --machine remote-a --project ```bash safe audit check express@4.21.0 --ecosystem npm +safe audit check express --ecosystem npm # unpinned: resolves the target first safe audit check ruff@0.11.0 --ecosystem python --json ``` -Checks include OSV, Socket package scoring when available, and the shared `safe run` blocklist. Verdicts are: +Checks are **version-aware**: the command first resolves the version the +package manager would actually install, then matches OSV advisories against +that exact version's affected ranges. + +- An exact version in the spec is used as-is (no network). +- An unpinned npm spec resolves through the registry packument: dist-tag for + installs, and with `--op update` the **in-range** target derived from the + project's `package.json` and lockfile ranges (what `npm update` would + install, not the dist-tag). +- pip/uv, cargo, composer, and go unpinned specs resolve to the registry's + latest release. +- When resolution fails (network, private registry, `||` ranges, git/workspace + specs), the check degrades to a package-level audit with a WARN floor and + reason `version_unresolved` — never a silent GO. + +Advisories are classified per resolved version as `affecting`, `remediated` +(fixed at or below the resolved version), `unfixed`, or `ambiguous`. Only +**affecting** advisories drive the verdict: a bump that is itself the +remediation gets a PASS with a notice naming the fixed advisories. An +affecting advisory whose severity is listed in `install.block_severities` +(default: `critical`) produces BLOCK; other affecting advisories produce +WARN. An OSV outage produces WARN (fail closed), never a zero-CVE PASS. + +Verdicts and exit codes are unchanged for consumers: ```text -GO -WARN -BLOCK +GO (exit 0) +WARN (exit 10) +BLOCK (exit 20) ``` -Socket is optional for command availability but improves package behavior scoring. Authenticate with: +### Install gate mode + +The shell install wrappers and `safe install` invoke the check as a gate: + +```bash +safe audit check --ecosystem npm --gate install --op install|update +``` + +Gate mode applies the allow policy itself and exits 0 when the install may +proceed: + +- **GO** proceeds and records the pinned resolved version in + `~/.config/safe/run/install-known.json` (machine-written evidence with a + pointer to the check receipt) — no operator terminal involved. +- **WARN** proceeds only when a pinned `host-allow` entry matches the + **resolved** version, or when every WARN cause is listed in the opt-in + `install.auto_allow_tolerate` config and no advisory affects the resolved + version. Otherwise it refuses (exit 10) with an actionable, always-pinned + hint — suggestions never use `@latest`. +- **BLOCK** refuses (exit 20) and points at operator review. + +A Socket scoring failure (missing CLI, auth, rate limit) is reported as an +infrastructure failure with a recovery path (`socket login`, `safe doctor`), +explicitly distinguished from a package finding. It still refuses by default: +fix the wiring rather than tolerating blind installs. + +Socket improves behavioral scoring. Authenticate with: ```bash socket login ``` -For predictable repeated use, use a Socket account token. The practical token scope for `socket package score` is `packages:list`. +For predictable repeated use, use a Socket account token. The practical token scope for `socket package score` is `packages:list`. `safe doctor` reports the Socket CLI and token wiring. ## Release Review diff --git a/inbox/2026-07-13-codex-acp-retire-volta-safe-integration.md b/inbox/2026-07-13-codex-acp-retire-volta-safe-integration.md new file mode 100644 index 0000000..b0e86fe --- /dev/null +++ b/inbox/2026-07-13-codex-acp-retire-volta-safe-integration.md @@ -0,0 +1,56 @@ +# Retire Volta-specific safe integration during the manager migration + +**Date:** 2026-07-13 +**Source:** codex-acp (Codex version-resolution investigation) +**Affects:** safe — install wrappers, safe-run runner resolution, and Volta compatibility + +## Observed + +Volta 2.0.2 is officially unmaintained and its maintainers recommend migrating +to mise. The Codex investigation also exposed two distinct behaviors: + +- Volta's `codex` shim intentionally selected the repository-local + `@openai/codex@0.144.1` instead of the globally installed `0.144.3`; this is + expected project-aware package routing, not a stale shim. +- `safe` incorrectly blocks the valid informational command `volta --version` + with exit 100 because the wrapper treats `--version` as an unrecognized + pre-subcommand flag. `command volta --version` succeeds. + +`safe` is coupled more deeply to Volta than the install wrapper alone: runtime +config stores `npx_real` paths under Volta, and `safe-run` contains +Volta-specific discovery and repair logic for per-version `npx.original` +binaries. + +A separate same-day inbox item, +`2026-07-13-michel-volta-exact-postinstall-allowlist.md`, proposes more +Volta-specific lifecycle-script machinery. Reassess that proposal against the +migration direction before investing in it. + +## Why it matters + +An unmaintained tool should not remain a growing compatibility surface inside +`safe`. Removing Volta without first replacing these assumptions could break +audited package execution or silently bypass the intended runner. Conversely, +continuing to add Volta-only fixes increases migration cost. + +## Suggested action + +1. Fix the generic wrapper-parser bug so informational top-level flags such as + `volta --version` pass through. +2. Inventory every Volta-specific path, shim backup, resolver, test fixture, + and runtime-config key in `safe`. +3. Define the desired mise boundary: which `mise` install/use operations + require `safe` audit receipts, and how mise npm-backend lifecycle-script + controls interact with `safe` policy. +4. Add manager-neutral tests for selecting the real npm/npx runner across Node + versions before replacing Volta-specific implementation. +5. Migrate one path at a time, retain explicit fail-closed behavior, and remove + Volta branches only after the machine-setup migration verifies all + consumers. +6. Reconcile or supersede the existing Volta postinstall-allowlist capture + instead of implementing a soon-to-be-retired integration. + +## References + +- Volta retirement notice: https://github.com/volta-cli/volta/issues/2080 +- mise npm backend: https://mise.jdx.dev/dev-tools/backends/npm.html diff --git a/inbox/2026-07-13-michel-volta-exact-postinstall-allowlist.md b/inbox/2026-07-13-michel-volta-exact-postinstall-allowlist.md new file mode 100644 index 0000000..7fb9d9b --- /dev/null +++ b/inbox/2026-07-13-michel-volta-exact-postinstall-allowlist.md @@ -0,0 +1,43 @@ +# Make audited Volta installs selectively run exact-version lifecycle scripts + +**Date:** 2026-07-13 +**Source:** michel (Volta/OpenCode failure investigation) +**Affects:** safe — npm package audit and approved-install workflow + +## Observed + +`~/.npmrc` has `ignore-scripts=true`. After a `safe audit` GO verdict, `volta install opencode-ai@1.17.18` installed OpenCode but skipped its required `postinstall.mjs`. The package entrypoint remained a 479-byte placeholder without a shebang, so Volta failed with `Exec format error (os error 8)`. Explicitly running the reviewed `postinstall.mjs` repaired it. + +Volta 2.0.2 has no package post-install event hook; its documented hooks only customize Node/npm/Yarn distribution downloads. npm 11.14.1 also has no selective lifecycle-script allowlist. npm 12 introduces `allow-scripts` and `strict-allow-scripts` for exact package identities in global/one-off install contexts. + +## Why it matters + +Keeping lifecycle scripts globally disabled is valuable, but an audited GO verdict currently cannot authorize a required script. This can produce an apparently successful yet broken Volta install. Temporarily enabling every lifecycle script would weaken the audit boundary and could execute unaudited transitive scripts. + +A sandbox is defense-in-depth, not the trust decision: a postinstall can generate the executable that will later run outside the sandbox with normal user access. + +## Suggested action + +Implement a fail-closed audited Volta install path in `safe`: + +1. Require an exact `package@version`; reject `@latest`, ranges, and name-only approvals. +2. Emit or consume a machine-readable GO receipt tied to the exact package version, registry integrity, and reviewed lifecycle scripts. +3. After separately auditing and adopting npm 12, invoke Volta only for the approved identity with per-command overrides, leaving global `ignore-scripts=true` unchanged: + + ```bash + NPM_CONFIG_IGNORE_SCRIPTS=false \ + NPM_CONFIG_ALLOW_SCRIPTS='opencode-ai@1.17.18' \ + NPM_CONFIG_STRICT_ALLOW_SCRIPTS=true \ + volta install opencode-ai@1.17.18 + ``` + +4. Fail if any transitive dependency has an unapproved lifecycle script. +5. Verify the resolved Volta package metadata, executable file type, and reported version before recording success. +6. Optionally pass lifecycle scripts through a `bubblewrap`-backed `script-shell`: clear the environment, hide the real home, use a private `/tmp`, mount the system read-only, and permit writes only to the installed package directory. Treat network access as exceptional; OpenCode currently may need it to fetch its version-pinned platform package. +7. Keep the npm 11 fallback explicit and package-specific: install with scripts disabled, then execute only the audited lifecycle script. Do not build a generic hook that automatically runs every skipped script. + +## References + +- npm 12 `allow-scripts`: https://docs.npmjs.com/cli/v12/using-npm/config/#allow-scripts +- npm 12 `npm approve-scripts`: https://docs.npmjs.com/cli/v12/commands/npm-approve-scripts/ +- Volta hooks scope: https://docs.volta.sh/advanced/hooks diff --git a/inbox/2026-07-31-claude-cvss-v4-scoring-proposal.md b/inbox/2026-07-31-claude-cvss-v4-scoring-proposal.md new file mode 100644 index 0000000..f757f04 --- /dev/null +++ b/inbox/2026-07-31-claude-cvss-v4-scoring-proposal.md @@ -0,0 +1,42 @@ +# CVSS v4-only advisories floor at "high" — scoring proposal needs a ruling + +**Date:** 2026-07-31 +**Source:** claude (safe overhaul session, PR#29 review finding 6 residual) +**Affects:** severity ladder in `safe audit check` (`vuln_sev` in bin/safe-audit) + +## What was observed + +The severity ladder computes CVSS v3.1 base scores (verified against the +FIRST spec, all 2,592 base combinations band-exact per the orthogonal +reviewer) and takes the most severe of {qualitative labels, computed v3 +bands}. An advisory whose ONLY severity signal is a CVSS **v4** vector is +floored at "high" without scoring the vector — so a v4-only *critical* +(e.g. FIRST's 10.0 example vector) lands at high → WARN-overridable instead +of BLOCK under the default `block_severities: ["critical"]`. + +The reviewer classified this ARCHITECTURAL (delta finding 6): closing it +properly means a verified v4 scorer, and the blunt alternative (blanket +BLOCK for unsupported v4 vectors) over-blocks genuine high/moderate cases — +a daily-UX regression that needs an operator ruling either way. + +## Exposure today + +Narrow: GHSA-sourced advisories (all of npm) carry a qualitative +`database_specific.severity`, which the ladder already uses. The gap is +advisories with standard `severity[]` v4 vectors and no qualitative label — +currently rare, will grow as v4 adoption spreads. + +## Options for ruling + +1. **Vendor a v4 scorer** (deterministic reference implementation, jq or a + small vendored script): correct bands, no UX change. Cost: v4 MacroVector + scoring is substantially more complex than v3 arithmetic; needs its own + verification pass. +2. **Blanket BLOCK on unscored v4-only**: safe but over-blocks; needs the + ruling explicitly because it can hard-block moderate advisories. +3. **Keep the high floor** (status quo): v4-only criticals stay + WARN-overridable — an operator override still requires a pinned + host-allow with a reason, so the residual is a conscious-override + surface, not a silent pass. + +No behavior change until ruled; option 3 is what ships in PR#29. diff --git a/inbox/2026-07-31-reveille-safe-blocks-security-bumps-cve-catch22.md b/inbox/2026-07-31-reveille-safe-blocks-security-bumps-cve-catch22.md new file mode 100644 index 0000000..4673499 --- /dev/null +++ b/inbox/2026-07-31-reveille-safe-blocks-security-bumps-cve-catch22.md @@ -0,0 +1,61 @@ +# safe blocks the dep bump that fixes the very CVE it's warning about — third occurrence + +**Date:** 2026-07-31 +**Source:** reveille (Dependabot audit-fix slice) +**Affects:** safe's npm-install audit verdict path; operator trust in the gate + +## What was observed + +During the reveille `npm audit fix` slice, `npm update brace-expansion esbuild +tsx` in `deploy/cdk8s/` was BLOCKED by safe with `VERDICT: WARN` on +`brace-expansion@latest`. The update's purpose was to close Dependabot alerts +GHSA-3jxr-9vmj-r5cp / GHSA-mh99-v99m-4gvg — i.e. the WARN almost certainly +keys off the same advisory data the bump remediates. The nested copy would +have resolved in-range to 2.1.4 (the patched 2.x tip), not to 5.x. + +Operator: this is the **third time** safe refuses to bump a dependency +because of the CVE the bump protects against. + +## Why it matters + +A security gate that reliably blocks remediation trains the operator to +bypass it. Michel explicitly does NOT want to start bypassing safe — this +capture exists so the gate gets less annoying instead. + +## Constraint (operator ruling, 2026-07-31) + +**We never `host-allow` `latest`.** Any allowlist entry is pinned to a +resolved version (e.g. `brace-expansion@2.1.4`), never a floating tag. The +suggestion safe prints on a block (`host-allow add @latest`) actively +recommends the wrong shape — worth fixing alongside the verdict logic. + +## Suggested direction (for the safe sibling session to design) + +When the audit verdict's findings for `@` consist of +advisories whose `first_patched_version` <= the resolved version (i.e. the +candidate IS the remediation), the verdict should not WARN-block — or at +minimum the block message should say "this version fixes the advisory that +triggered the WARN" and offer the pinned allow line. Also consider +resolving/printing the actual version instead of `@latest` in the +suggestion. + +## Second data point (same day): pinned allows can never match `npm update` + +The operator added the pinned entry +(`safe run host-allow add brace-expansion@2.1.4 --reason ...`) and the retry +was STILL blocked: the npm-update wrapper audits the literal spec +`brace-expansion@latest` (npm update carries no version), so a pinned +allowlist entry has nothing to match against. The parent-package route +(`npm update minimatch`) WARN-blocks identically on `minimatch@latest`. +Net effect: for `npm update`-shaped security bumps, the ONLY working allow +shape is `@latest` — which the never-host-allow-latest rule forbids. The +gate and the ruling are currently mutually exclusive on this path; the fix +needs safe to resolve the real in-range target version (or accept a pin +match against the resolved version) before verdicting. + +## Immediate state + +Residual left in place; Dependabot alert 42 dismissed as tolerable_risk +(CI-only exposure). The pinned allow entry `brace-expansion@2.1.4` is on the +host allowlist, currently unmatchable — remove or keep as the sibling +session prefers. diff --git a/lib/install-wrappers.zsh b/lib/install-wrappers.zsh index dcc3451..5faf6f3 100644 --- a/lib/install-wrappers.zsh +++ b/lib/install-wrappers.zsh @@ -40,13 +40,207 @@ safe_install_warn_missing() { } safe_install_run_audit() { + local -a extra=() + [[ -n "${SAFE_INSTALL_DIST_TAG:-}" ]] && extra+=(--dist-tag "${SAFE_INSTALL_DIST_TAG}") + [[ -n "${SAFE_INSTALL_REGISTRY:-}" ]] && extra+=(--registry "${SAFE_INSTALL_REGISTRY}") + [[ -n "${SAFE_INSTALL_PROJECT_DIR:-}" ]] && extra+=(--project-dir "${SAFE_INSTALL_PROJECT_DIR}") + [[ -n "${SAFE_INSTALL_NPM_USERCONFIG:-}" ]] && extra+=(--npm-userconfig "${SAFE_INSTALL_NPM_USERCONFIG}") + [[ -n "${SAFE_INSTALL_NPM_GLOBALCONFIG:-}" ]] && extra+=(--npm-globalconfig "${SAFE_INSTALL_NPM_GLOBALCONFIG}") if (( $+commands[timeout] )); then - command timeout "${_SAFE_INSTALL_TIMEOUT_SECONDS:-${SAFE_INSTALL_TIMEOUT_SECONDS:-30}}" safe audit check "$@" + command timeout "${_SAFE_INSTALL_TIMEOUT_SECONDS:-${SAFE_INSTALL_TIMEOUT_SECONDS:-30}}" safe audit check "$@" "${extra[@]}" else - command safe audit check "$@" + command safe audit check "$@" "${extra[@]}" fi } +# Target-altering selectors change what the package manager actually +# installs (tag, source registry/index, project dir). They were previously +# stripped from the audit while staying in the real command — the audit then +# judged the wrong target (PR#29 review finding 3). Scan the original argv +# (never inside a $() parser subshell: globals must survive) and thread the +# values into the audit; safe-audit resolves with them or fails closed. +# Source selectors are cumulative (pip considers --find-links AND the index): +# accumulate every one — a later trusted selector must never erase an earlier +# untrusted one (PR#29 delta-2 finding 3). A REPEATED selector moves to the +# END: npm options are last-wins, and the resolver reads the last word to +# audit what npm actually fetches (delta-4 finding 3.1) — the trust sweep is +# order-independent, so the move is safe for cumulative families too. +# Trailing slashes normalize away so writer and readers derive identical +# source identities (delta-4 finding N1). +safe_install_add_source() { + local value="$1" + while [[ "${value}" == */ ]]; do value="${value%/}"; done + # The accumulated set stays OPERATIONAL (credentials included): safe-audit + # fetches packuments from it, and pre-redacting here broke authenticated + # registries (delta-6 finding N2). Redaction happens centrally in + # safe-audit at every identity/receipt/display sink. + [[ -n "${value}" ]] || return 0 + local -a kept=() + local w + for w in ${=SAFE_INSTALL_REGISTRY}; do + [[ "${w}" == "${value}" ]] || kept+=("${w}") + done + kept+=("${value}") + SAFE_INSTALL_REGISTRY="${kept[*]}" +} + +# Alternate npm config files are threaded to safe-audit AS PATHS, never +# flattened into the source set: extracting their values here lost the +# key/scope and npm's config-tier precedence (wrong-registry resolution), +# and copied any inline credentials out of the protected file into argv and +# shell state (delta-7 findings 3.2b and N2). safe-audit resolves the +# effective endpoint from the file itself. + +safe_install_scan_target_flags() { + local family="$1" + shift + typeset -g SAFE_INSTALL_DIST_TAG="" + typeset -g SAFE_INSTALL_REGISTRY="" + typeset -g SAFE_INSTALL_PROJECT_DIR="" + typeset -g SAFE_INSTALL_NPM_USERCONFIG="" + typeset -g SAFE_INSTALL_NPM_GLOBALCONFIG="" + local prev="" arg + for arg in "$@"; do + case "${family}" in + npm) + case "${prev}" in + --tag) SAFE_INSTALL_DIST_TAG="${arg}" ;; + # --@scope:registry is a scoped source selector npm accepts on the + # command line (delta-5 finding 3.2). It is threaded as a KEYED + # token (@scope:registry=URL) — flattening the bare URL loses the + # key and lets the resolver pick a different scope's registry + # (delta-6 finding 3.2b). --userconfig/--globalconfig swap in + # whole config files whose registry keys become effective. + --registry) safe_install_add_source "${arg}" ;; + --@*:registry) safe_install_add_source "${prev#--}=${arg}" ;; + --userconfig) SAFE_INSTALL_NPM_USERCONFIG="${arg}" ;; + --globalconfig) SAFE_INSTALL_NPM_GLOBALCONFIG="${arg}" ;; + --prefix|-C|--cwd|--dir) SAFE_INSTALL_PROJECT_DIR="${arg}" ;; + esac + case "${arg}" in + --tag=*) SAFE_INSTALL_DIST_TAG="${arg#*=}" ;; + --registry=*) safe_install_add_source "${arg#*=}" ;; + --@*:registry=*) safe_install_add_source "${arg#--}" ;; + --userconfig=*) SAFE_INSTALL_NPM_USERCONFIG="${arg#*=}" ;; + --globalconfig=*) SAFE_INSTALL_NPM_GLOBALCONFIG="${arg#*=}" ;; + --prefix=*|--cwd=*|--dir=*) SAFE_INSTALL_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + python) + case "${prev}" in + --index-url|-i|--extra-index-url|--default-index|--index) safe_install_add_source "${arg}" ;; + # find-links names a real endpoint: record it so operators can + # trust a specific location instead of a blanket sentinel + # (delta-5 finding 3.2 mitigation). + --find-links|-f) safe_install_add_source "${arg}" ;; + esac + case "${arg}" in + --index-url=*|--extra-index-url=*|--default-index=*|--index=*) safe_install_add_source "${arg#*=}" ;; + --find-links=*) safe_install_add_source "${arg#*=}" ;; + --no-index) safe_install_add_source "local:no-index" ;; + esac + ;; + cargo) + case "${prev}" in + --registry|--index) safe_install_add_source "${arg}" ;; + esac + case "${arg}" in + --registry=*|--index=*) safe_install_add_source "${arg#*=}" ;; + esac + ;; + composer) + case "${prev}" in + --repository) safe_install_add_source "${arg}" ;; + --working-dir|-d) SAFE_INSTALL_PROJECT_DIR="${arg}" ;; + esac + case "${arg}" in + --repository=*) safe_install_add_source "${arg#*=}" ;; + --working-dir=*) SAFE_INSTALL_PROJECT_DIR="${arg#*=}" ;; + esac + ;; + esac + prev="${arg}" + done +} + +# Update-family subcommands resolve in-range instead of to the dist-tag; the +# audit needs to know which semantics apply to resolve the real target. +safe_install_audit_op() { + case "${SAFE_INSTALL_SUBCMD:-}" in + update|u|up|upgrade|udpate) print -rn -- "update" ;; + *) print -rn -- "install" ;; + esac +} + +# Persistent record of install-gate decisions, mirroring safe-run's audit_log +# field format (ts | runner | pkg | tier | context | decision | extra). +safe_install_audit_log() { + local ecosystem="$1" package="$2" decision="$3" extra="${4:-}" + local data_dir="${SAFE_RUN_DATA_DIR:-${SAFE_DATA_DIR:-$HOME/.local/share/safe}/run}" + local context="non-tty" + [[ -t 0 && -t 1 ]] && context="interactive" + mkdir -p "${data_dir}" 2>/dev/null || return 0 + local line + line="$(command date -Iseconds) | install:${ecosystem} | ${package} | GATE | ${context} | ${decision}${extra:+ | ${extra}}" + print -r -- "${line//[$'\r\n']/ }" >> "${data_dir}/audit.log" 2>/dev/null || true +} + +safe_install_known_file() { + print -r -- "${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}/install-known.json" +} + +# Offline fallback: when the audit itself timed out, an exact-version request +# matching a fresh install-known entry (a previously recorded clean check) may +# proceed on that stale evidence. Unpinned requests never qualify. +safe_install_known_matches() { + local package="$1" + local ecosystem="$2" + local known_file name version entry ttl_days entry_epoch now_epoch + + # install-known entries are keyed by the canonical resolver ecosystem + # (safe-audit normalizes at check entry); the wrappers speak tool labels. + case "${ecosystem}" in + cargo) ecosystem="rust" ;; + composer) ecosystem="php" ;; + pip|uv) ecosystem="python" ;; + esac + + (( $+commands[jq] )) || return 1 + known_file="$(safe_install_known_file)" + [[ -r "${known_file}" ]] || return 1 + + IFS=$'\t' read -r name version <<< "$(safe_install_split_spec "${package}")" + [[ -n "${name}" && -n "${version}" && "${version}" != "latest" ]] || return 1 + + # Only fully clean GO evidence may satisfy the offline fallback — a + # tolerated-WARN record is not clean evidence (PR#29 review finding 5). + # The source identity (implicit-default vs explicit:, delta-3 + # finding 5) must be byte-identical to what safe-audit's receipt writer + # recorded; the derivation (argv + env + rc precedence, per ecosystem) is + # centralized in safe-audit — ask it instead of mirroring (delta-4 + # findings 3.2 and N1). Local-only plumbing, no network. No identity -> + # fail closed: stale evidence never applies without a trustworthy match. + local -a es_args=("${name}" --ecosystem "${ecosystem}") + [[ -n "${SAFE_INSTALL_REGISTRY:-}" ]] && es_args+=(--registry "${SAFE_INSTALL_REGISTRY}") + [[ -n "${SAFE_INSTALL_PROJECT_DIR:-}" ]] && es_args+=(--project-dir "${SAFE_INSTALL_PROJECT_DIR}") + [[ -n "${SAFE_INSTALL_NPM_USERCONFIG:-}" ]] && es_args+=(--npm-userconfig "${SAFE_INSTALL_NPM_USERCONFIG}") + [[ -n "${SAFE_INSTALL_NPM_GLOBALCONFIG:-}" ]] && es_args+=(--npm-globalconfig "${SAFE_INSTALL_NPM_GLOBALCONFIG}") + local current_source + current_source="$(command safe audit effective-sources "${es_args[@]}" 2>/dev/null)" || return 1 + [[ -n "${current_source}" ]] || return 1 + entry="$(jq -r --arg k "${ecosystem}:${name}" --arg v "${version}" --arg src "${current_source}" \ + '.packages[$k] | select(.version == $v and .verdict == "GO" and ((.source // "implicit-default") == $src)) | .first_allowed // empty' "${known_file}" 2>/dev/null || true)" + [[ -n "${entry}" ]] || return 1 + + ttl_days="$(jq -r '.install.auto_allow_ttl_days // 30' \ + "${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}/config.json" 2>/dev/null || print -rn 30)" + [[ "${ttl_days}" == <-> ]] || ttl_days=30 + entry_epoch="$(command date -d "${entry}" +%s 2>/dev/null || true)" + [[ -n "${entry_epoch}" ]] || return 1 + now_epoch="$(command date +%s)" + (( now_epoch - entry_epoch <= ttl_days * 86400 )) +} + safe_install_host_allow_file() { print -r -- "${SAFE_RUN_CONFIG_DIR:-${SAFE_CONFIG_DIR:-$HOME/.config/safe}/run}/host-allow.json" } @@ -223,18 +417,27 @@ safe_install_pip_project_install() { # One-line operator hint for a refused package. host-allow only unlocks npm # installs, so other ecosystems point at the audit detail command instead. +# Fallback hint when the audit could not supply a pinned suggestion (the gate +# prints resolved-version hints itself). Must never render an @latest shape: +# allow entries are always pinned to an exact resolved version. safe_install_allow_hint() { local package="$1" local ecosystem="$2" + local name version + IFS=$'\t' read -r name version <<< "$(safe_install_split_spec "${package}")" if [[ "${ecosystem}" == "npm" ]]; then - print -rn -- "to allow: ask the operator to run: safe run host-allow add ${package} --reason \"...\" — then retry" + if [[ -n "${version}" && "${version}" != "latest" ]]; then + print -rn -- "to allow: ask the operator to run: safe run host-allow add ${package} --reason \"...\" — then retry" + else + print -rn -- "to allow: pin an exact version first (see the resolved-version hint above) — allow entries are never @latest" + fi else print -rn -- "to allow: operator review — safe audit check ${package} --ecosystem ${ecosystem}" fi } -# Check one package via safe audit. +# Check one package via safe audit's install gate. # Returns: 0=GO/proceed, 100=policy refusal, 104=audit BLOCK verdict. safe_install_check() { local package="$1" @@ -246,46 +449,73 @@ safe_install_check() { return 0 fi - safe_install_run_audit "${package}" --ecosystem "${ecosystem}" + safe_install_run_audit "${package}" --ecosystem "${ecosystem}" \ + --gate install --op "$(safe_install_audit_op)" audit_status=$? case "${audit_status}" in 0) + safe_install_audit_log "${ecosystem}" "${package}" "PROCEED" return 0 ;; 1|10) if safe_install_host_allow_matches "${package}" "${ecosystem}"; then print -u2 -- "safe install: safe audit warned for ${package}; exact host-allow entry permits install" + safe_install_audit_log "${ecosystem}" "${package}" "HOST_ALLOW_OVERRIDE" return 0 fi print -u2 -- "safe: BLOCKED ${ecosystem} install of ${package} — safe audit verdict WARN; $(safe_install_allow_hint "${package}" "${ecosystem}"); details: safe explain" + safe_install_audit_log "${ecosystem}" "${package}" "REFUSED_WARN" return 100 ;; 2|20) print -u2 -- "safe: BLOCKED ${ecosystem} install of ${package} — safe audit verdict BLOCK; $(safe_install_allow_hint "${package}" "${ecosystem}"); details: safe explain" + safe_install_audit_log "${ecosystem}" "${package}" "REFUSED_BLOCK" return 104 ;; 124|137) + if safe_install_known_matches "${package}" "${ecosystem}"; then + print -u2 -- "safe install: safe audit timed out; proceeding on recorded clean check for ${package} (stale evidence)" + safe_install_audit_log "${ecosystem}" "${package}" "STALE_EVIDENCE" + return 0 + fi print -u2 -- "safe: BLOCKED ${ecosystem} install of ${package} — safe audit timed out (fail closed); retry or ask the operator; details: safe explain" + safe_install_audit_log "${ecosystem}" "${package}" "TIMEOUT_FAILCLOSED" return 100 ;; *) print -u2 -- "safe: BLOCKED ${ecosystem} install of ${package} — safe audit failed with exit ${audit_status} (fail closed); ask the operator; details: safe explain" + safe_install_audit_log "${ecosystem}" "${package}" "REFUSED_AUDIT_ERROR" "exit=${audit_status}" return 100 ;; esac } +# Scanned target state (which may carry an operational credential from an +# argv --registry) never outlives the gate decision: this shell is +# long-lived, and a lingering global is a credential-lifetime leak +# (delta-7 finding N2). Cleared on EVERY exit path, after the last package. +safe_install_clear_target_flags() { + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" +} + safe_install_check_many() { local ecosystem="$1" shift - local package + local package rc=0 for package in "$@"; do - safe_install_check "${package}" "${ecosystem}" || return $? + safe_install_check "${package}" "${ecosystem}" + rc=$? + (( rc != 0 )) && break done - return 0 + safe_install_clear_target_flags + return "${rc}" } safe_install_check_packages() { @@ -318,7 +548,7 @@ safe_install_npm_like_packages() { -g|--global|-f|--force|-D|--dev|-P|--peer|-O|--optional|-E|--exact|-T|--tilde|--cached|--save|--save-prod|--save-dev|--save-optional|--save-peer|--no-save|--prefer-offline|--prefer-online|--legacy-peer-deps|--strict-peer-deps|--dry-run|--package-lock-only|--workspace-root|--ignore-scripts|--foreground-scripts) continue ;; - --tag|--registry|--cache|--prefix|--userconfig|--globalconfig|--workspace|-w|--filter|--omit|--include|--install-strategy|--save-prefix|--mode|--cwd) + --tag|--registry|--@*:registry|--cache|--prefix|--userconfig|--globalconfig|--workspace|-w|--filter|--omit|--include|--install-strategy|--save-prefix|--mode|--cwd) skip_next=1 continue ;; @@ -361,20 +591,37 @@ safe_install_cargo_packages() { local -a packages local arg local skip_next=0 + local capture_version=0 + local crate_version="" packages=() for arg in "$@"; do + if (( capture_version )); then + crate_version="${arg}" + capture_version=0 + continue + fi if (( skip_next )); then skip_next=0 continue fi case "${arg}" in - --version|--vers|--index|--registry|--root|--target|--features|--bin|--example) + --version|--vers) + # `cargo install --version ` pins the crate; dropping the + # value would audit the bare name (PR#29 review finding 7). + capture_version=1 + continue + ;; + --version=*|--vers=*) + crate_version="${arg#*=}" + continue + ;; + --index|--registry|--root|--target|--features|--bin|--example) skip_next=1 continue ;; - --version=*|--vers=*|--index=*|--registry=*|--root=*|--target=*|--features=*|--bin=*|--example=*) + --index=*|--registry=*|--root=*|--target=*|--features=*|--bin=*|--example=*) continue ;; --locked|--offline|--quiet|--debug|--force|-f|--list|--no-track|--all-features|--no-default-features) @@ -384,11 +631,19 @@ safe_install_cargo_packages() { continue ;; *) - packages+=("${arg}@latest") + packages+=("${arg}") ;; esac done + if [[ -n "${crate_version}" ]]; then + local -a versioned=() + for arg in "${packages[@]}"; do + versioned+=("${arg}@${crate_version}") + done + packages=("${versioned[@]}") + fi + print -r -l -- "${packages[@]}" } @@ -487,7 +742,7 @@ safe_install_npm_spec() { version="latest" fi - print -r -- "${name}@${version}" + safe_install_print_spec "${name}" "${version}" } safe_install_python_spec() { @@ -505,7 +760,7 @@ safe_install_python_spec() { version="latest" fi - print -r -- "${name}@${version}" + safe_install_print_spec "${name}" "${version}" } safe_install_colon_spec() { @@ -520,7 +775,7 @@ safe_install_colon_spec() { version="latest" fi - print -r -- "${name}@${version}" + safe_install_print_spec "${name}" "${version}" } safe_install_go_spec() { @@ -535,7 +790,21 @@ safe_install_go_spec() { version="latest" fi - print -r -- "${name}@${version}" + safe_install_print_spec "${name}" "${version}" +} + +# An unpinned request is audited as the bare name: safe audit resolves the +# real target version itself (a fabricated @latest is version-blind and made +# pinned host-allow entries unmatchable). +safe_install_print_spec() { + local name="$1" + local version="$2" + + if [[ -z "${version}" || "${version}" == "latest" ]]; then + print -r -- "${name}" + else + print -r -- "${name}@${version}" + fi } # A bare command name has no package-spec syntax: no version/tag/alias. @@ -821,9 +1090,28 @@ safe_install_exec_gate() { } safe_install_npm_like() { + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + safe_install_npm_like_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +safe_install_npm_like_impl() { local tool="$1" shift safe_install_route "${tool}" "$@" || return $? + safe_install_scan_target_flags npm "$@" local subcommand="${SAFE_INSTALL_SUBCMD}" local -a rest raw packages rest=("${@:$((SAFE_INSTALL_SUBCMD_IDX + 1))}") @@ -1079,9 +1367,28 @@ safe_install_python_packages() { } safe_install_pip_like() { + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + safe_install_pip_like_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +safe_install_pip_like_impl() { local tool="$1" shift safe_install_route "${tool}" "$@" || return $? + safe_install_scan_target_flags python "$@" local subcommand="${SAFE_INSTALL_SUBCMD}" local subidx=$SAFE_INSTALL_SUBCMD_IDX local -a packages @@ -1135,7 +1442,10 @@ pip3() { } uv() { - if ! typeset -f safe_install_python_packages safe_install_check >/dev/null 2>&1; then + # INLINE degraded guard (see the contract comment above npm()): the + # impl and cleanup helpers can be stripped by shell snapshots, so the + # public wrapper must refuse legibly on its own (delta-9 finding N4). + if ! typeset -f uv_impl safe_install_python_packages safe_install_check >/dev/null 2>&1; then # Scan every token for a gated subcommand so a leading global flag can't # hide it (`uv --offline tool run x`). `tool` covers tool run/install. local __a @@ -1158,7 +1468,26 @@ uv() { fi command uv "$@"; return $? fi + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + uv_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +uv_impl() { safe_install_route uv "$@" || return $? + safe_install_scan_target_flags python "$@" local first="${SAFE_INSTALL_SUBCMD}" local subidx=$SAFE_INSTALL_SUBCMD_IDX local second="${@[subidx+1]:-}" @@ -1274,7 +1603,10 @@ uv() { } cargo() { - if ! typeset -f safe_install_cargo_packages safe_install_check >/dev/null 2>&1; then + # INLINE degraded guard (see the contract comment above npm()): the + # impl and cleanup helpers can be stripped by shell snapshots, so the + # public wrapper must refuse legibly on its own (delta-9 finding N4). + if ! typeset -f cargo_impl safe_install_cargo_packages safe_install_check >/dev/null 2>&1; then local __a for __a in "$@"; do case "${__a}" in @@ -1285,7 +1617,26 @@ cargo() { done command cargo "$@"; return $? fi + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + cargo_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +cargo_impl() { safe_install_route cargo "$@" || return $? + safe_install_scan_target_flags cargo "$@" local subcommand="${SAFE_INSTALL_SUBCMD}" local subidx=$SAFE_INSTALL_SUBCMD_IDX local -a packages @@ -1318,7 +1669,10 @@ cargo() { } go() { - if ! typeset -f safe_install_go_packages safe_install_check >/dev/null 2>&1; then + # INLINE degraded guard (see the contract comment above npm()): the + # impl and cleanup helpers can be stripped by shell snapshots, so the + # public wrapper must refuse legibly on its own (delta-9 finding N4). + if ! typeset -f go_impl safe_install_go_packages safe_install_check >/dev/null 2>&1; then # Scan every token: `go install` (any position) and `go run @` # fetch. `go get` stays out of scope (its fetch runs no code), so the '@' # check is scoped to a `run` present — not any '@' anywhere. @@ -1338,7 +1692,26 @@ go() { fi command go "$@"; return $? fi + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + go_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +go_impl() { safe_install_route go "$@" || return $? + safe_install_scan_target_flags go "$@" local subcommand="${SAFE_INSTALL_SUBCMD}" local subidx=$SAFE_INSTALL_SUBCMD_IDX local second="${@[subidx+1]:-}" @@ -1409,7 +1782,10 @@ go() { } composer() { - if ! typeset -f safe_install_composer_packages safe_install_check >/dev/null 2>&1; then + # INLINE degraded guard (see the contract comment above npm()): the + # impl and cleanup helpers can be stripped by shell snapshots, so the + # public wrapper must refuse legibly on its own (delta-9 finding N4). + if ! typeset -f composer_impl safe_install_composer_packages safe_install_check >/dev/null 2>&1; then local __a for __a in "$@"; do case "${__a}" in @@ -1420,7 +1796,26 @@ composer() { done command composer "$@"; return $? fi + # Scanned target state (possibly credential-bearing) is cleared on EVERY + # exit of a scanning route — project scans, parser fallbacks, unsupported + # subcommands, refusals, and successful gates alike (delta-8 finding N2: + # cleanup tied only to check_many missed the project-install paths). + { + composer_impl "$@" + } always { + # Inline assignments — a cleanup HELPER could itself be stripped by a + # partial shell snapshot (delta-9 finding N4). + SAFE_INSTALL_DIST_TAG="" + SAFE_INSTALL_REGISTRY="" + SAFE_INSTALL_PROJECT_DIR="" + SAFE_INSTALL_NPM_USERCONFIG="" + SAFE_INSTALL_NPM_GLOBALCONFIG="" + } +} + +composer_impl() { safe_install_route composer "$@" || return $? + safe_install_scan_target_flags composer "$@" local first="${SAFE_INSTALL_SUBCMD}" local subidx=$SAFE_INSTALL_SUBCMD_IDX local second="${@[subidx+1]:-}" diff --git a/tests/audit/check_version_aware.sh b/tests/audit/check_version_aware.sh new file mode 100755 index 0000000..811e24a --- /dev/null +++ b/tests/audit/check_version_aware.sh @@ -0,0 +1,1890 @@ +#!/usr/bin/env bash +# Version-aware `safe audit check` suite: target-version resolution, OSV +# server-authoritative classification, pagination, install-gate mode, +# install-known recording/revocation, and the pinned (never @latest) refusal +# hints. Fully offline: curl and socket are mocked on PATH. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SAFE_AUDIT="$ROOT/bin/safe-audit" + +PASS_COUNT=0 +FAIL_COUNT=0 + +pass() { printf 'ok - %s\n' "$*"; PASS_COUNT=$((PASS_COUNT + 1)); } +fail() { printf 'not ok - %s\n' "$*" >&2; FAIL_COUNT=$((FAIL_COUNT + 1)); } + +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +MOCKBIN="$TEST_ROOT/mockbin" +mkdir -p "$MOCKBIN" + +# --- mock curl --------------------------------------------------------------- +# Registry requests serve MOCK_REGISTRY_FIXTURE (fail with MOCK_REGISTRY_STATUS). +# OSV requests are version-aware: when the posted body carries a version equal +# to MOCK_OSV_MATCH_VERSION the fixture is served, otherwise an empty vulns +# page — mirroring the real server-side matching of /v1/query. Package-only +# queries (no version in body) always get the fixture. MOCK_OSV_PAGES= +# serves /page1.json, page2.json… per call instead (pagination tests). +cat > "$MOCKBIN/curl" <<'MOCK' +#!/usr/bin/env bash +url="" +outfile="" +data="" +args=("$@") +i=0 +while (( i < ${#args[@]} )); do + a="${args[$i]}" + case "$a" in + -H|--max-time) i=$((i + 2)); continue ;; + -d) data="${args[$((i + 1))]}"; i=$((i + 2)); continue ;; + -o) outfile="${args[$((i + 1))]}"; i=$((i + 2)); continue ;; + http*://*) url="$a" ;; + esac + i=$((i + 1)) +done +emit() { + if [[ -n "$outfile" ]]; then cat > "$outfile"; else cat; fi +} +case "$url" in + *api.osv.dev*) + [[ "${MOCK_OSV_STATUS:-0}" == "0" ]] || exit 22 + if [[ -n "${MOCK_OSV_PAGES:-}" ]]; then + count_file="${MOCK_OSV_PAGES}/.count" + count=$(( $(cat "$count_file" 2>/dev/null || echo 0) + 1 )) + printf '%s' "$count" > "$count_file" + cat "${MOCK_OSV_PAGES}/page${count}.json" | emit + exit 0 + fi + body_version=$(printf '%s' "$data" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') + if [[ -z "$body_version" || "$body_version" == "${MOCK_OSV_MATCH_VERSION:-}" ]]; then + cat "${MOCK_OSV_FIXTURE:?}" | emit + else + printf '{"vulns": []}' | emit + fi + ;; + *) + [[ "${MOCK_REGISTRY_STATUS:-0}" == "0" ]] || exit 22 + printf '%s\n' "$url" >> "${MOCK_REGISTRY_URL_LOG:-/dev/null}" + cat "${MOCK_REGISTRY_FIXTURE:?}" | emit + ;; +esac +exit 0 +MOCK +chmod +x "$MOCKBIN/curl" + +# --- mock socket CLI --------------------------------------------------------- +cat > "$MOCKBIN/socket" <<'MOCK' +#!/usr/bin/env bash +case "${MOCK_SOCKET_MODE:-ok}" in + ok) printf '{"score": 95}\n'; exit 0 ;; + low) printf '{"score": 40}\n'; exit 0 ;; + auth) printf '{"message":"Unauthorized","cause":"401 Unauthorized"}\n'; exit 1 ;; + rate) printf '{"message":"Too Many Requests","cause":"429"}\n'; exit 1 ;; + *) exit 1 ;; +esac +MOCK +chmod +x "$MOCKBIN/socket" + +FIXTURES="$TEST_ROOT/fixtures" +mkdir -p "$FIXTURES" + +# brace-expansion-like packument: 1.x line, 2.x line (tip 2.1.4), latest 5.0.1 +cat > "$FIXTURES/packument.json" <<'JSON' +{ + "dist-tags": {"latest": "5.0.1", "beta": "2.1.3", "next": "6.0.0-rc.1"}, + "versions": { + "1.1.11": {}, "1.1.12": {}, + "2.0.0": {}, "2.0.1": {}, "2.1.3": {}, "2.1.4": {}, + "3.0.0-rc.1": {}, + "4.0.0": {}, "5.0.0": {}, "5.0.1": {} + } +} +JSON + +# npm latest-tag preference: latest=1.2.0 satisfies ^1.0.0 although 1.3.0 exists +cat > "$FIXTURES/packument-latestpref.json" <<'JSON' +{ + "dist-tags": {"latest": "1.2.0"}, + "versions": {"1.1.0": {}, "1.2.0": {}, "1.3.0": {}} +} +JSON + +cat > "$FIXTURES/crates.json" <<'JSON' +{"crate": {"max_stable_version": "14.1.1", "newest_version": "14.1.1"}} +JSON + +cat > "$FIXTURES/packagist.json" <<'JSON' +{"packages": {"vendor/pkg": [{"version": "2.5.0"}, {"version": "2.4.0"}]}} +JSON + +osv_fixture_empty() { + printf '{"vulns": []}' > "$FIXTURES/osv-empty.json" + printf '%s' "$FIXTURES/osv-empty.json" +} + +# - GHSA-AFFECTS-MOD: moderate, matched by the server for the queried version +osv_fixture_affecting_moderate() { + cat > "$FIXTURES/osv-affect-mod.json" <<'JSON' +{"vulns": [ + {"id": "GHSA-AFFECTS-MOD", "database_specific": {"severity": "MODERATE"}, + "affected": [{"package": {"ecosystem": "npm", "name": "brace-expansion"}, + "ranges": [{"type": "SEMVER", "events": [{"introduced": "2.0.0"}, {"fixed": "2.9.9"}]}]}]} +]} +JSON + printf '%s' "$FIXTURES/osv-affect-mod.json" +} + +osv_fixture_affecting_critical() { + cat > "$FIXTURES/osv-affect-crit.json" <<'JSON' +{"vulns": [ + {"id": "GHSA-AFFECTS-CRIT", "database_specific": {"severity": "critical"}, + "affected": [{"package": {"ecosystem": "npm", "name": "brace-expansion"}, + "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]}]} +]} +JSON + printf '%s' "$FIXTURES/osv-affect-crit.json" +} + +# Server-matched advisory whose LOCAL range data claims the version is fixed — +# the exact-query server hit must win (review finding 1: the local comparator +# must never downgrade a version-scoped result to GO). +osv_fixture_server_hit_local_miss() { + cat > "$FIXTURES/osv-server-hit.json" <<'JSON' +{"vulns": [ + {"id": "GHSA-SERVER-HIT", "database_specific": {"severity": "HIGH"}, + "affected": [{"package": {"ecosystem": "npm", "name": "brace-expansion"}, + "ranges": [{"type": "SEMVER", "events": [{"introduced": "1.0.0-alpha.2"}, {"fixed": "1.0.0"}]}]}]} +]} +JSON + printf '%s' "$FIXTURES/osv-server-hit.json" +} + +osv_fixture_git_only() { + cat > "$FIXTURES/osv-git-only.json" <<'JSON' +{"vulns": [ + {"id": "GHSA-GIT-ONLY", "database_specific": {"severity": "LOW"}, + "affected": [{"package": {"ecosystem": "npm", "name": "brace-expansion"}, + "ranges": [{"type": "GIT", "repo": "https://example.invalid/r", "events": [{"introduced": "abc123"}]}]}]} +]} +JSON + printf '%s' "$FIXTURES/osv-git-only.json" +} + +# No database_specific.severity; only a standard CVSS v3 vector (9.8 critical). +osv_fixture_cvss_critical() { + cat > "$FIXTURES/osv-cvss-crit.json" <<'JSON' +{"vulns": [ + {"id": "GHSA-CVSS-ONLY", + "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}], + "affected": [{"package": {"ecosystem": "npm", "name": "brace-expansion"}, + "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}]}]}]} +]} +JSON + printf '%s' "$FIXTURES/osv-cvss-crit.json" +} + +CASE_DIR="" +CASE_RUN_CONFIG="" +CASE_HOME="" +CASE_PROJECT="" +CASE_CHECKS_DIR="" + +prepare_case() { + local name="$1" + CASE_DIR="$TEST_ROOT/case-$name" + CASE_RUN_CONFIG="$CASE_DIR/run-config" + CASE_HOME="$CASE_DIR/home" + CASE_PROJECT="$CASE_DIR/project" + CASE_CHECKS_DIR="$CASE_DIR/audit-data/checks" + mkdir -p "$CASE_RUN_CONFIG" "$CASE_HOME" "$CASE_PROJECT" "$CASE_DIR/audit-config" "$CASE_DIR/audit-data" +} + +# run_check -- +run_check() { + local -a envs=() + while [[ "$1" != "--" ]]; do + envs+=("$1") + shift + done + shift + OUT_FILE="$CASE_DIR/stdout.log" + ERR_FILE="$CASE_DIR/stderr.log" + set +e + ( + cd "$CASE_PROJECT" || exit 99 + env \ + HOME="$CASE_HOME" \ + PATH="$MOCKBIN:/usr/bin:/bin" \ + SAFE_RUN_CONFIG_DIR="$CASE_RUN_CONFIG" \ + SAFE_AUDIT_CONFIG_DIR="$CASE_DIR/audit-config" \ + SAFE_AUDIT_DATA_DIR="$CASE_DIR/audit-data" \ + "${envs[@]}" \ + "$SAFE_AUDIT" check "$@" + ) > "$OUT_FILE" 2> "$ERR_FILE" + STATUS=$? + set -e +} + +expect_status() { + local expected="$1" label="$2" + if [[ "$STATUS" -ne "$expected" ]]; then + printf 'expected exit %s, got %s\nstdout:\n%s\nstderr:\n%s\n' \ + "$expected" "$STATUS" "$(cat "$OUT_FILE")" "$(cat "$ERR_FILE")" >&2 + fail "$label" + return 1 + fi + return 0 +} + +expect_grep() { + local file="$1" pattern="$2" label="$3" + if ! grep -qE "$pattern" "$file"; then + printf 'pattern not found: %s\nin:\n%s\n' "$pattern" "$(cat "$file")" >&2 + fail "$label" + return 1 + fi + return 0 +} + +expect_no_grep() { + local file="$1" pattern="$2" label="$3" + if grep -qE "$pattern" "$file"; then + printf 'forbidden pattern found: %s\nin:\n%s\n' "$pattern" "$(cat "$file")" >&2 + fail "$label" + return 1 + fi + return 0 +} + +# --------------------------------------------------------------------------- +# 1. Exact clean version (server returns nothing), socket ok -> GO + record +# --------------------------------------------------------------------------- +prepare_case exact-clean +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 0 "exact clean version gates GO"; then + pass "exact clean version gates GO" +fi +if jq -e '.packages["npm:brace-expansion"] | .version == "2.1.4" and .verdict == "GO"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "install-known entry recorded with pinned version" +else + cat "$CASE_RUN_CONFIG/install-known.json" 2>/dev/null >&2 || true + fail "install-known entry recorded with pinned version" +fi + +# --------------------------------------------------------------------------- +# 2. npm update shape: unversioned + --op update resolves in-range (2.1.4) +# --------------------------------------------------------------------------- +prepare_case update-in-range +printf '{"dependencies": {"brace-expansion": "^2.0.0"}}\n' > "$CASE_PROJECT/package.json" +cat > "$CASE_PROJECT/package-lock.json" <<'JSON' +{"packages": {"node_modules/minimatch": {"dependencies": {"brace-expansion": "^2.0.1"}}}} +JSON +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 0 "update op resolves in-range and gates GO"; then + pass "update op resolves in-range and gates GO" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*2\.1\.4' "resolved line shows the in-range target"; then + pass "resolved line shows the in-range target" +fi +if expect_no_grep "$OUT_FILE" '5\.0\.1' "dist-tag latest is not the audited version on update"; then + pass "dist-tag latest is not the audited version on update" +fi +receipt="$CASE_CHECKS_DIR/$(date +%F)-brace-expansion-2.1.4.json" +if [[ -f "$receipt" ]] && jq -e '.resolution.method == "project-range" and .resolved_versions == ["2.1.4"]' "$receipt" >/dev/null; then + pass "receipt records resolution evidence" +else + ls "$CASE_CHECKS_DIR" >&2 || true + fail "receipt records resolution evidence" +fi + +# --------------------------------------------------------------------------- +# 3. Server hit whose local ranges disagree must stay affecting (finding 1) +# --------------------------------------------------------------------------- +prepare_case server-hit-wins +fixture="$(osv_fixture_server_hit_local_miss)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "version-scoped server hit is authoritative over local ranges"; then + pass "version-scoped server hit is authoritative over local ranges" +fi + +# --------------------------------------------------------------------------- +# 4. Critical advisory affecting the resolved version -> BLOCK (gate exit 20) +# --------------------------------------------------------------------------- +prepare_case affect-critical +fixture="$(osv_fixture_affecting_critical)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 20 "critical advisory on resolved version blocks"; then + pass "critical advisory on resolved version blocks" +fi + +# --------------------------------------------------------------------------- +# 5. Standard CVSS v3 critical with no database_specific -> still BLOCK +# --------------------------------------------------------------------------- +prepare_case cvss-critical +fixture="$(osv_fixture_cvss_critical)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 20 "standard CVSS v3 critical blocks without database_specific"; then + pass "standard CVSS v3 critical blocks without database_specific" +fi + +# --------------------------------------------------------------------------- +# 6a. Affecting moderate -> WARN refusal with pinned host-allow hint +# --------------------------------------------------------------------------- +prepare_case affect-moderate +fixture="$(osv_fixture_affecting_moderate)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "moderate affecting advisory refuses at the gate"; then + pass "moderate affecting advisory refuses at the gate" +fi +if expect_grep "$ERR_FILE" 'host-allow add brace-expansion@2\.1\.4' "refusal hint is pinned to the resolved version"; then + pass "refusal hint is pinned to the resolved version" +fi +if expect_no_grep "$ERR_FILE" '@latest' "refusal hint never suggests @latest"; then + pass "refusal hint never suggests @latest" +fi + +# --------------------------------------------------------------------------- +# 6b. Same, but a pinned host-allow entry matching the resolved version allows +# --------------------------------------------------------------------------- +prepare_case host-allow-resolved +printf '{"dependencies": {"brace-expansion": "^2.0.0"}}\n' > "$CASE_PROJECT/package.json" +printf '{"packages": {"brace-expansion": {"version": "2.1.4", "ecosystem": "npm", "added": "2026-07-31", "reason": "test"}}}\n' \ + > "$CASE_RUN_CONFIG/host-allow.json" +fixture="$(osv_fixture_affecting_moderate)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 0 "pinned host-allow entry matches the RESOLVED version of an unversioned spec"; then + pass "pinned host-allow entry matches the RESOLVED version of an unversioned spec" +fi +if expect_grep "$ERR_FILE" 'host-allow entry brace-expansion@2\.1\.4 matches' "override notice names the pin"; then + pass "override notice names the pin" +fi + +# --------------------------------------------------------------------------- +# 7. Gate WARN/BLOCK revokes a stale install-known entry for the same version +# --------------------------------------------------------------------------- +prepare_case revoke-on-adverse +printf '{"packages": {"npm:brace-expansion": {"version": "2.1.4", "verdict": "GO", "reasons": [], "evidence": "x", "first_allowed": "2026-07-31T10:00:00+02:00", "last_used": "2026-07-31", "times_used": 1}}}\n' \ + > "$CASE_RUN_CONFIG/install-known.json" +fixture="$(osv_fixture_affecting_moderate)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "adverse check still refuses"; then + pass "adverse check still refuses" +fi +if jq -e '.packages["npm:brace-expansion"] == null' "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "adverse check revokes the stale install-known entry" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "adverse check revokes the stale install-known entry" +fi + +# --------------------------------------------------------------------------- +# 8a. Socket unavailable, OSV clean -> refusal legible as infrastructure +# --------------------------------------------------------------------------- +prepare_case socket-down +mv "$MOCKBIN/socket" "$MOCKBIN/socket.hidden" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +mv "$MOCKBIN/socket.hidden" "$MOCKBIN/socket" +if expect_status 10 "socket outage still refuses (never tolerated silently)"; then + pass "socket outage still refuses (never tolerated silently)" +fi +if expect_grep "$ERR_FILE" 'infrastructure failure, NOT a package finding' "socket refusal reads as breakage, not a CVE"; then + pass "socket refusal reads as breakage, not a CVE" +fi +if expect_grep "$ERR_FILE" 'fix now:' "socket refusal carries a recovery path"; then + pass "socket refusal carries a recovery path" +fi + +# --------------------------------------------------------------------------- +# 8b. Operator opt-in tolerate knob allows socket-outage WARN when OSV clean +# --------------------------------------------------------------------------- +prepare_case socket-tolerated +printf '{"install": {"auto_allow_tolerate": ["socket_unavailable"]}}\n' > "$CASE_RUN_CONFIG/config.json" +mv "$MOCKBIN/socket" "$MOCKBIN/socket.hidden" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +mv "$MOCKBIN/socket.hidden" "$MOCKBIN/socket" +if expect_status 0 "auto_allow_tolerate opt-in allows the tolerated cause"; then + pass "auto_allow_tolerate opt-in allows the tolerated cause" +fi +if jq -e '.packages["npm:brace-expansion"].verdict == "WARN_TOLERATED"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "tolerated allow recorded with its reasons" +else + fail "tolerated allow recorded with its reasons" +fi +# A tolerated record must be distinguishable from clean GO evidence: the +# offline timeout fallback readers require verdict == "GO". +if jq -e '.packages["npm:brace-expansion"].verdict != "GO"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "tolerated record is not disguised as clean GO evidence" +else + fail "tolerated record is not disguised as clean GO evidence" +fi + +# --------------------------------------------------------------------------- +# 9. Unresolvable range (||) -> degraded WARN with pin guidance +# --------------------------------------------------------------------------- +prepare_case unresolved-range +printf '{"dependencies": {"brace-expansion": "^1.0.0 || ^2.0.0"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 10 "unresolvable range degrades to WARN refusal"; then + pass "unresolvable range degrades to WARN refusal" +fi +if expect_grep "$ERR_FILE" 'pin an exact version' "unresolved refusal tells the caller to pin"; then + pass "unresolved refusal tells the caller to pin" +fi + +# --------------------------------------------------------------------------- +# 10. overrides mentioning the package -> unresolved (npm may force a version) +# --------------------------------------------------------------------------- +prepare_case overrides-degrade +printf '{"dependencies": {"brace-expansion": "^2.0.0"}, "overrides": {"brace-expansion": "1.1.11"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 10 "root overrides degrade resolution instead of guessing"; then + pass "root overrides degrade resolution instead of guessing" +fi +if expect_grep "$OUT_FILE" 'version unresolved' "overrides path is explicit"; then + pass "overrides path is explicit" +fi + +# --------------------------------------------------------------------------- +# 11. Glob regression: a file matching the range must not alter parsing +# --------------------------------------------------------------------------- +prepare_case glob-range +printf '{"dependencies": {"brace-expansion": "1.*"}}\n' > "$CASE_PROJECT/package.json" +touch "$CASE_PROJECT/1.0.0" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 0 "1.* range resolves despite a matching filename in cwd"; then + pass "1.* range resolves despite a matching filename in cwd" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.1\.12' "1.* resolves to the 1.x tip, not the filename"; then + pass "1.* resolves to the 1.x tip, not the filename" +fi + +# --------------------------------------------------------------------------- +# 12. npm latest-tag preference: latest wins when it satisfies the range +# --------------------------------------------------------------------------- +prepare_case latest-pref +printf '{"dependencies": {"demo-pkg": "^1.0.0"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-latestpref.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --op update --gate install +if expect_status 0 "latest-tag preference case gates GO"; then + pass "latest-tag preference case gates GO" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.2\.0' "npm picks the satisfying latest tag, not plain max"; then + pass "npm picks the satisfying latest tag, not plain max" +fi + +# --------------------------------------------------------------------------- +# 13. --dist-tag changes what an unversioned install audits +# --------------------------------------------------------------------------- +prepare_case dist-tag-flag +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --dist-tag beta --gate install +if expect_status 0 "--dist-tag resolves through the named tag"; then + pass "--dist-tag resolves through the named tag" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*2\.1\.3' "--dist-tag beta audits the beta target"; then + pass "--dist-tag beta audits the beta target" +fi + +# --------------------------------------------------------------------------- +# 14. --registry is used as the npm resolution source, and an UNTRUSTED +# custom source floors at WARN even though resolution succeeded +# --------------------------------------------------------------------------- +prepare_case registry-flag +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --registry https://registry.example --gate install +if expect_status 10 "untrusted custom registry floors at WARN"; then + pass "untrusted custom registry floors at WARN" +fi +if expect_grep "$CASE_DIR/urls.log" '^https://registry\.example/brace-expansion$' "packument fetched from the command-line registry"; then + pass "packument fetched from the command-line registry" +fi + +# --------------------------------------------------------------------------- +# 15. A custom python index makes the default-registry lookup unresolvable +# --------------------------------------------------------------------------- +prepare_case python-index +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests --ecosystem python --registry https://index.example --gate install +if expect_status 10 "custom python index degrades instead of auditing PyPI"; then + pass "custom python index degrades instead of auditing PyPI" +fi + +# --------------------------------------------------------------------------- +# 16. cargo/composer ecosystem labels reach their resolvers (finding 7) +# --------------------------------------------------------------------------- +prepare_case cargo-eco +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/crates.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- ripgrep --ecosystem cargo --gate install +if expect_status 0 "cargo ecosystem resolves via crates.io"; then + pass "cargo ecosystem resolves via crates.io" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*14\.1\.1' "cargo unpinned resolves to the stable tip"; then + pass "cargo unpinned resolves to the stable tip" +fi + +prepare_case composer-eco +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packagist.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- vendor/pkg --ecosystem composer --gate install +if expect_status 0 "composer ecosystem resolves via packagist"; then + pass "composer ecosystem resolves via packagist" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*2\.5\.0' "composer unpinned resolves to the newest release"; then + pass "composer unpinned resolves to the newest release" +fi + +# --------------------------------------------------------------------------- +# 17. OSV pagination: token-only first page, findings on page 2 (finding 2) +# --------------------------------------------------------------------------- +prepare_case pagination +PAGES="$CASE_DIR/pages" +mkdir -p "$PAGES" +printf '{"next_page_token": "opaque-token-1"}' > "$PAGES/page1.json" +cp "$(osv_fixture_affecting_critical)" "$PAGES/page2.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_PAGES="$PAGES" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 20 "advisories on a later OSV page still block"; then + pass "advisories on a later OSV page still block" +fi + +prepare_case pagination-loop +PAGES="$CASE_DIR/pages" +mkdir -p "$PAGES" +printf '{"next_page_token": "same-token"}' > "$PAGES/page1.json" +printf '{"next_page_token": "same-token"}' > "$PAGES/page2.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_PAGES="$PAGES" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "a repeated pagination token fails closed"; then + pass "a repeated pagination token fails closed" +fi +if expect_grep "$OUT_FILE" 'OSV query failed|OSV pagination|OSV response' "pagination anomaly is explicit"; then + pass "pagination anomaly is explicit" +fi + +# --------------------------------------------------------------------------- +# 18. Registry failure -> degraded WARN, never GO +# --------------------------------------------------------------------------- +prepare_case registry-down +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_STATUS=22 \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --gate install +if expect_status 10 "registry outage degrades to WARN refusal"; then + pass "registry outage degrades to WARN refusal" +fi + +# --------------------------------------------------------------------------- +# 19. OSV outage with a resolved version -> WARN (fail closed, was fail-open) +# --------------------------------------------------------------------------- +prepare_case osv-down +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_STATUS=22 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "OSV outage fails closed instead of counting zero CVEs"; then + pass "OSV outage fails closed instead of counting zero CVEs" +fi +if expect_grep "$OUT_FILE" 'OSV query failed' "OSV outage is explicit in the check output"; then + pass "OSV outage is explicit in the check output" +fi + +# --------------------------------------------------------------------------- +# 20. GIT-only advisory on the exact path is trusted as affecting (WARN) +# --------------------------------------------------------------------------- +prepare_case git-only +fixture="$(osv_fixture_git_only)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "unparseable advisory ranges stay WARN on the exact path"; then + pass "unparseable advisory ranges stay WARN on the exact path" +fi + +# --------------------------------------------------------------------------- +# 21. Non-gate mode is unchanged for consumers: WARN -> 10, GO -> 0 +# --------------------------------------------------------------------------- +prepare_case plain-warn +fixture="$(osv_fixture_affecting_moderate)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm +if expect_status 10 "plain check keeps WARN=10 for existing consumers"; then + pass "plain check keeps WARN=10 for existing consumers" +fi +if [[ ! -f "$CASE_RUN_CONFIG/install-known.json" ]]; then + pass "plain check never writes install-known" +else + fail "plain check never writes install-known" +fi + +prepare_case plain-go +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --json +if expect_status 0 "plain check keeps GO=0"; then + pass "plain check keeps GO=0" +fi +if jq -e '.verdict == "GO" and .resolution.status == "ok" and (.osv.classification.affecting | length) == 0' "$OUT_FILE" >/dev/null 2>&1; then + pass "check --json exposes resolution and classification" +else + cat "$OUT_FILE" >&2 + fail "check --json exposes resolution and classification" +fi + +# --------------------------------------------------------------------------- +# 22. Malformed pagination token shape must not read as zero advisories +# --------------------------------------------------------------------------- +prepare_case pagination-bad-token +PAGES="$CASE_DIR/pages" +mkdir -p "$PAGES" +printf '{"next_page_token": false}' > "$PAGES/page1.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_PAGES="$PAGES" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "non-string pagination token fails closed"; then + pass "non-string pagination token fails closed" +fi + +# --------------------------------------------------------------------------- +# 23. vparse orders SemVer prerelease identifiers per spec (unit-level) +# --------------------------------------------------------------------------- +sed '$d' "$SAFE_AUDIT" > "$TEST_ROOT/audit-lib.sh" +if ( + SAFE_AUDIT_NO_INIT=1 + # shellcheck source=/dev/null + source "$TEST_ROOT/audit-lib.sh" >/dev/null 2>&1 + jq -n "$JQ_VSEMVER"' + vlt("1.0.0-alpha.2"; "1.0.0-alpha.10") + and vlt("1.0.0-alpha.10"; "1.0.0") + and vlt("1.0.0-alpha"; "1.0.0-alpha.1") + and vlt("1.0.0-alpha.beta"; "1.0.0-beta") + and vlt("0e0.0.0"; "0.0.1") + and vlt("1e2.0.0"; "1.0.0") + ' | grep -q '^true$' +); then + pass "prerelease identifiers compare per SemVer, not lexically" +else + fail "prerelease identifiers compare per SemVer, not lexically" +fi + +# --------------------------------------------------------------------------- +# 24. Version-qualified override keys degrade resolution too +# --------------------------------------------------------------------------- +prepare_case overrides-qualified +printf '{"dependencies": {"brace-expansion": "^2.0.0"}, "overrides": {"brace-expansion@^2.0.0": "1.1.11"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --op update --gate install +if expect_status 10 "version-qualified override key degrades resolution"; then + pass "version-qualified override key degrades resolution" +fi + +# --------------------------------------------------------------------------- +# 25. Custom source floors at WARN even for exact versions (delta finding 3) +# --------------------------------------------------------------------------- +prepare_case custom-source-exact +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --registry https://private.example --gate install +if expect_status 10 "exact version from a custom source still floors at WARN"; then + pass "exact version from a custom source still floors at WARN" +fi +if expect_grep "$ERR_FILE" 'trusted_registries' "custom-source refusal names the trust knob"; then + pass "custom-source refusal names the trust knob" +fi + +# --------------------------------------------------------------------------- +# 26. install.trusted_registries lifts the custom-source floor +# --------------------------------------------------------------------------- +prepare_case trusted-registry +printf '{"install": {"trusted_registries": ["https://registry.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --registry https://registry.example --gate install +if expect_status 0 "an operator-trusted registry audits like the default source"; then + pass "an operator-trusted registry audits like the default source" +fi + +# --------------------------------------------------------------------------- +# 27. Host-allow override still revokes stale clean evidence (delta finding 5) +# --------------------------------------------------------------------------- +prepare_case override-still-revokes +printf '{"dependencies": {"brace-expansion": "^2.0.0"}}\n' > "$CASE_PROJECT/package.json" +printf '{"packages": {"brace-expansion": {"version": "2.1.4", "ecosystem": "npm", "added": "2026-07-31", "reason": "test"}}}\n' \ + > "$CASE_RUN_CONFIG/host-allow.json" +printf '{"packages": {"npm:brace-expansion": {"version": "2.1.4", "verdict": "GO", "reasons": [], "evidence": "x", "first_allowed": "2026-07-31T10:00:00+02:00", "last_used": "2026-07-31", "times_used": 1}}}\n' \ + > "$CASE_RUN_CONFIG/install-known.json" +fixture="$(osv_fixture_affecting_moderate)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_OSV_MATCH_VERSION=2.1.4 \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 0 "pinned host-allow still permits this invocation"; then + pass "pinned host-allow still permits this invocation" +fi +if jq -e '.packages["npm:brace-expansion"] == null' "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "stale clean evidence is revoked even when host-allow overrides" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "stale clean evidence is revoked even when host-allow overrides" +fi + + +# --------------------------------------------------------------------------- +# 28. A whitespace-only pagination token is rejected, not read as completion +# --------------------------------------------------------------------------- +prepare_case pagination-ws-token +PAGES="$CASE_DIR/pages" +mkdir -p "$PAGES" +# printf '%s' so the file is VALID JSON whose decoded token is a real +# newline — a format-string \n would break the JSON itself and exercise the +# generic malformed-JSON path instead (delta-3 F2 test nit). +printf '%s' '{"vulns": [], "next_page_token": "\n"}' > "$PAGES/page1.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_PAGES="$PAGES" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "whitespace pagination token fails closed"; then + pass "whitespace pagination token fails closed" +fi + +# --------------------------------------------------------------------------- +# 29. jq-numeric-but-SemVer-alphanumeric prerelease ids ("1e-2") never let +# the resolver pick a version npm would reject +# --------------------------------------------------------------------------- +prepare_case semver-numeric-grammar +cat > "$FIXTURES/packument-1e2.json" <<'JSON' +{ + "dist-tags": {"latest": "1.0.0-1e-2"}, + "versions": {"1.0.0-1e-2": {}, "1.0.0-1": {}} +} +JSON +printf '{"dependencies": {"demo-pkg": "<=1.0.0-1"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-1e2.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --op update --gate install +if expect_status 10 "alphanumeric 1e-2 prerelease cannot satisfy a numeric bound"; then + pass "alphanumeric 1e-2 prerelease cannot satisfy a numeric bound" +fi +if expect_no_grep "$OUT_FILE" 'Resolved:.*1e-2' "the wrong tagged version is never audited as the target"; then + pass "the wrong tagged version is never audited as the target" +fi + +# --------------------------------------------------------------------------- +# 30. Trust matching is origin-bounded: pypi.org.evil.example is NOT PyPI +# --------------------------------------------------------------------------- +prepare_case evil-hostname +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --registry https://pypi.org.evil.example/simple --gate install +if expect_status 10 "a pypi.org-prefixed attacker hostname is not trusted"; then + pass "a pypi.org-prefixed attacker hostname is not trusted" +fi + +prepare_case real-pypi-trusted +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --registry https://pypi.org/simple --gate install +if expect_status 0 "the real PyPI origin stays trusted"; then + pass "the real PyPI origin stays trusted" +fi + +# --------------------------------------------------------------------------- +# 31. Cumulative sources: one untrusted source floors even beside a trusted one +# --------------------------------------------------------------------------- +prepare_case multi-source +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --registry 'local:find-links https://pypi.org/simple' --gate install +if expect_status 10 "an untrusted source in a cumulative set floors at WARN"; then + pass "an untrusted source in a cumulative set floors at WARN" +fi + +# --------------------------------------------------------------------------- +# 32. A low Socket score is adverse evidence: it revokes stale clean receipts +# --------------------------------------------------------------------------- +prepare_case low-score-revokes +printf '{"packages": {"npm:brace-expansion": {"version": "2.1.4", "verdict": "GO", "reasons": [], "evidence": "x", "source": "default", "first_allowed": "2026-07-31T10:00:00+02:00", "last_used": "2026-07-31", "times_used": 1}}}\n' \ + > "$CASE_RUN_CONFIG/install-known.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=low \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "low socket score refuses"; then + pass "low socket score refuses" +fi +if jq -e '.packages["npm:brace-expansion"] == null' "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "low socket score revokes stale clean evidence" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "low socket score revokes stale clean evidence" +fi + +# --------------------------------------------------------------------------- +# 33. Receipts are source-scoped on write +# --------------------------------------------------------------------------- +prepare_case receipt-source-scope +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --registry https://mirror.example --gate install +if expect_status 0 "trusted mirror gates GO"; then + pass "trusted mirror gates GO" +fi +if jq -e '.packages["npm:brace-expansion"].source == "explicit:https://mirror.example"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "receipt records the source it was gathered against" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "receipt records the source it was gathered against" +fi + +# --------------------------------------------------------------------------- +# 34. npm repeated --registry: resolution follows npm's LAST-wins semantics +# (delta-3 finding 3.1) while the floor still sees every selector +# --------------------------------------------------------------------------- +prepare_case npm-registry-last-wins +printf '{"install": {"trusted_registries": ["https://first.example", "https://second.example"]}}\n' \ + > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/registry-urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --registry 'https://first.example https://second.example' --gate install +if expect_status 0 "two operator-trusted registries still gate GO"; then + pass "two operator-trusted registries still gate GO" +fi +if grep -q '^https://second\.example/' "$CASE_DIR/registry-urls.log" \ + && ! grep -q '^https://first\.example/' "$CASE_DIR/registry-urls.log"; then + pass "npm resolution fetches from the LAST registry (npm last-wins)" +else + cat "$CASE_DIR/registry-urls.log" >&2 + fail "npm resolution fetches from the LAST registry (npm last-wins)" +fi + +# --------------------------------------------------------------------------- +# 35. NPM_CONFIG_REGISTRY reaches the trust floor even on an exact pin +# (delta-3 finding 3.2: effective sources outside argv) +# --------------------------------------------------------------------------- +prepare_case env-registry-floor +fixture="$(osv_fixture_empty)" +run_check \ + NPM_CONFIG_REGISTRY=https://evil.example \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "env-configured registry floors an exact pin at WARN"; then + pass "env-configured registry floors an exact pin at WARN" +fi +if expect_grep "$ERR_FILE" 'custom package source' "env-registry refusal names the custom source"; then + pass "env-registry refusal names the custom source" +fi + +# --------------------------------------------------------------------------- +# 36. A project .npmrc registry line reaches the trust floor too +# --------------------------------------------------------------------------- +prepare_case npmrc-registry-floor +printf 'registry=https://corp.example/npm/\n' > "$CASE_PROJECT/.npmrc" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "project .npmrc registry floors an exact pin at WARN"; then + pass "project .npmrc registry floors an exact pin at WARN" +fi + +# --------------------------------------------------------------------------- +# 37. A trusted env-configured mirror stays frictionless and the receipt is +# scoped to it (UX-preserving mitigation for finding 3.2) +# --------------------------------------------------------------------------- +prepare_case env-registry-trusted +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + NPM_CONFIG_REGISTRY=https://mirror.example \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 0 "a trusted env-configured mirror gates GO"; then + pass "a trusted env-configured mirror gates GO" +fi +if jq -e '.packages["npm:brace-expansion"].source == "explicit:https://mirror.example"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "receipt identity carries the env-configured source" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "receipt identity carries the env-configured source" +fi + +# --------------------------------------------------------------------------- +# 38. Malformed packument core versions never win selection: npm's selector +# rejects "0e0.0.0"; ours must pick what npm actually installs +# (delta-3 finding 4) +# --------------------------------------------------------------------------- +prepare_case malformed-core +cat > "$FIXTURES/packument-malformed.json" <<'JSON' +{ + "dist-tags": {"latest": "0e0.0.0"}, + "versions": {"0e0.0.0": {}, "1.0.0": {}} +} +JSON +printf '{"dependencies": {"demo-pkg": "<=1.0.0"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-malformed.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --op update --gate install +if expect_status 0 "malformed candidates are excluded from selection"; then + pass "malformed candidates are excluded from selection" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.0\.0' "the well-formed in-range version is audited"; then + pass "the well-formed in-range version is audited" +fi +if expect_no_grep "$OUT_FILE" '0e0' "the malformed version is never the audited target"; then + pass "the malformed version is never the audited target" +fi + +# --------------------------------------------------------------------------- +# 39. A malformed dist-tag TARGET degrades instead of being audited +# --------------------------------------------------------------------------- +prepare_case malformed-dist-tag +cat > "$FIXTURES/packument-malformed-only.json" <<'JSON' +{ + "dist-tags": {"latest": "0e0.0.0"}, + "versions": {"0e0.0.0": {}} +} +JSON +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-malformed-only.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --gate install +if expect_status 10 "a malformed dist-tag target degrades to unresolved"; then + pass "a malformed dist-tag target degrades to unresolved" +fi +if expect_grep "$ERR_FILE" 'pin an exact version' "degraded refusal keeps the pin guidance"; then + pass "degraded refusal keeps the pin guidance" +fi + +# --------------------------------------------------------------------------- +# 40. Default-source receipts carry the implicit-default sentinel; a literal +# "--registry default" floors (untrusted) and never writes a colliding +# receipt (delta-3 finding 5) +# --------------------------------------------------------------------------- +prepare_case implicit-default-sentinel +printf '{"dependencies": {"brace-expansion": "^2.0.0"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 0 "default-source exact pin gates GO"; then + pass "default-source exact pin gates GO" +fi +if jq -e '.packages["npm:brace-expansion"].source == "implicit-default"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "default-source receipt carries the implicit-default sentinel" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "default-source receipt carries the implicit-default sentinel" +fi +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- ripgrep@14.1.1 --ecosystem cargo --registry default --gate install +if expect_status 10 "a literal 'default' selector is an untrusted explicit source"; then + pass "a literal 'default' selector is an untrusted explicit source" +fi + +# --------------------------------------------------------------------------- +# 41. Lowercase npm_config_registry is as effective as the uppercase form +# (delta-4 finding 3.2) +# --------------------------------------------------------------------------- +prepare_case env-registry-lowercase +fixture="$(osv_fixture_empty)" +run_check \ + npm_config_registry=https://evil.example \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "lowercase npm_config_registry floors an exact pin at WARN"; then + pass "lowercase npm_config_registry floors an exact pin at WARN" +fi + +# --------------------------------------------------------------------------- +# 42. A scoped @scope:registry .npmrc key routes the scoped package to a +# custom registry — it must reach the floor even beside argv selectors +# (delta-4 finding 3.2) +# --------------------------------------------------------------------------- +prepare_case scoped-registry-floor +printf '@demo:registry=https://scoped.example\n' > "$CASE_PROJECT/.npmrc" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- @demo/pkg@1.2.3 --ecosystem npm --gate install +if expect_status 10 "a scoped .npmrc registry floors the scoped package"; then + pass "a scoped .npmrc registry floors the scoped package" +fi +: > "$CASE_DIR/stderr.log" +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- @demo/pkg@1.2.3 --ecosystem npm --registry https://registry.npmjs.org --gate install +if expect_status 10 "the scoped key floors even beside a trusted argv selector"; then + pass "the scoped key floors even beside a trusted argv selector" +fi + +# --------------------------------------------------------------------------- +# 43. Malformed PRERELEASE identifiers never win selection either: npm +# rejects leading-zero numeric identifiers like 1.0.0-01 +# (delta-4 finding 4) +# --------------------------------------------------------------------------- +prepare_case malformed-prerelease +cat > "$FIXTURES/packument-pre01.json" <<'JSON' +{ + "dist-tags": {"latest": "1.0.0-01"}, + "versions": {"1.0.0-01": {}, "1.0.0": {}} +} +JSON +printf '{"dependencies": {"demo-pkg": "<=1.0.0"}}\n' > "$CASE_PROJECT/package.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-pre01.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --op update --gate install +if expect_status 0 "a leading-zero prerelease candidate is excluded"; then + pass "a leading-zero prerelease candidate is excluded" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.0\.0' "npm-oracle: the valid version npm installs is audited"; then + pass "npm-oracle: the valid version npm installs is audited" +fi +if expect_no_grep "$OUT_FILE" '1\.0\.0-01' "the malformed prerelease is never the target"; then + pass "the malformed prerelease is never the target" +fi +prepare_case malformed-prerelease-tag +cat > "$FIXTURES/packument-pre01-only.json" <<'JSON' +{ + "dist-tags": {"latest": "1.0.0-01"}, + "versions": {"1.0.0-01": {}} +} +JSON +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-pre01-only.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --gate install +if expect_status 10 "a malformed prerelease dist-tag target degrades"; then + pass "a malformed prerelease dist-tag target degrades" +fi + +# --------------------------------------------------------------------------- +# 44. version_is_exact enforces full SemVer grammar (unit-level) +# --------------------------------------------------------------------------- +if ( + SAFE_AUDIT_NO_INIT=1 + # shellcheck source=/dev/null + source "$TEST_ROOT/audit-lib.sh" >/dev/null 2>&1 + for bad in 1.0.0-01 01.0.0 1.0.0-alpha..1 1.0.0-.alpha 1.0.0-alpha. 0e0.0.0; do + ! version_is_exact npm "$bad" || exit 1 + done + for good in 1.0.0 1.0.0-alpha.1 1.0.0-0 1.0.0+build.1 v1.0.0-rc.1+meta.2; do + version_is_exact npm "$good" || exit 1 + done + version_is_exact go v1.2.3 || exit 1 + ! version_is_exact go 1.2.3 || exit 1 +); then + pass "version_is_exact enforces full SemVer grammar" +else + fail "version_is_exact enforces full SemVer grammar" +fi + +# --------------------------------------------------------------------------- +# 45. effective-sources plumbing: single derivation for readers +# (delta-4 findings 3.2 and N1) +# --------------------------------------------------------------------------- +prepare_case effective-sources-plumbing +run_es() { + ( + cd "$CASE_PROJECT" || exit 99 + env \ + HOME="$CASE_HOME" \ + PATH="$MOCKBIN:/usr/bin:/bin" \ + SAFE_RUN_CONFIG_DIR="$CASE_RUN_CONFIG" \ + SAFE_AUDIT_CONFIG_DIR="$CASE_DIR/audit-config" \ + SAFE_AUDIT_DATA_DIR="$CASE_DIR/audit-data" \ + "$@" \ + "$SAFE_AUDIT" effective-sources "${ES_ARGS[@]}" + ) +} +ES_ARGS=(okpkg --ecosystem npm) +if [[ "$(run_es)" == "implicit-default" ]]; then + pass "plumbing: no sources -> implicit-default" +else + fail "plumbing: no sources -> implicit-default" +fi +ES_ARGS=(okpkg --ecosystem npm --registry 'https://a.example/ https://a.example') +if [[ "$(run_es)" == "explicit:https://a.example" ]]; then + pass "plumbing: trailing slashes and repeats canonicalize (N1)" +else + fail "plumbing: trailing slashes and repeats canonicalize (N1)" +fi +ES_ARGS=(okpkg --ecosystem npm --registry 'https://a.example https://b.example https://a.example') +if [[ "$(run_es)" == "explicit:https://b.example https://a.example" ]]; then + pass "plumbing: a repeated selector keeps its LAST position (3.1)" +else + fail "plumbing: a repeated selector keeps its LAST position (3.1)" +fi +ES_ARGS=(okpkg --ecosystem npm) +if [[ "$(run_es NPM_CONFIG_REGISTRY=https://mirror.example/)" == "explicit:https://mirror.example" ]]; then + pass "plumbing: env registry becomes an explicit identity" +else + fail "plumbing: env registry becomes an explicit identity" +fi +ES_ARGS=(okpkg --ecosystem cargo --registry default) +if [[ "$(run_es)" == "explicit:default" ]]; then + pass "plumbing: a literal default selector stays explicit" +else + fail "plumbing: a literal default selector stays explicit" +fi +ES_ARGS=(@demo/pkg --ecosystem npm --registry '@demo:registry=https://alice:pw@scoped.example/') +if [[ "$(run_es)" == "explicit:@demo:registry=https://scoped.example" ]]; then + pass "plumbing: scoped tokens keep their key, redacted and canonical" +else + printf 'got: %s\n' "$(run_es)" >&2 + fail "plumbing: scoped tokens keep their key, redacted and canonical" +fi + +# --------------------------------------------------------------------------- +# 46. Receipt identity is canonicalized: a trailing-slash argv selector and +# its slashless twin are the same source (delta-4 finding N1) +# --------------------------------------------------------------------------- +prepare_case receipt-canonical +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --registry https://mirror.example/ --gate install +if expect_status 0 "trailing-slash trusted mirror gates GO"; then + pass "trailing-slash trusted mirror gates GO" +fi +if jq -e '.packages["npm:brace-expansion"].source == "explicit:https://mirror.example"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1; then + pass "receipt identity is slash-canonical" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "receipt identity is slash-canonical" +fi + +# --------------------------------------------------------------------------- +# 47. Sibling manager env sources reach the floor: PIP_INDEX_URL and GOPROXY +# (delta-4 finding 3.2) +# --------------------------------------------------------------------------- +prepare_case pip-env-floor +fixture="$(osv_fixture_empty)" +run_check \ + PIP_INDEX_URL=https://evil.example/simple \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "PIP_INDEX_URL floors an exact python pin at WARN"; then + pass "PIP_INDEX_URL floors an exact python pin at WARN" +fi +prepare_case goproxy-floor +run_check \ + GOPROXY='https://corp.example,direct' \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- golang.org/x/tools@v0.1.0 --ecosystem go --gate install +if expect_status 10 "a custom GOPROXY floors a go pin at WARN"; then + pass "a custom GOPROXY floors a go pin at WARN" +fi + +# --------------------------------------------------------------------------- +# 48. A trusted pip mirror stays frictionless (UX preservation for 3.2) +# --------------------------------------------------------------------------- +prepare_case pip-env-trusted +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + PIP_INDEX_URL=https://mirror.example/simple \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 0 "a trusted pip mirror gates GO without friction"; then + pass "a trusted pip mirror gates GO without friction" +fi + +# --------------------------------------------------------------------------- +# 49. When both env case forms are set, the LOWERCASE one wins — npm's own +# precedence (delta-5 finding 3.2) +# --------------------------------------------------------------------------- +prepare_case env-registry-case-precedence +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + NPM_CONFIG_REGISTRY=https://mirror.example \ + npm_config_registry=https://evil.example \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "lowercase env wins over a trusted uppercase mirror"; then + pass "lowercase env wins over a trusted uppercase mirror" +fi + +# --------------------------------------------------------------------------- +# 50. node-semver limits: a numeric core beyond MAX_SAFE_INTEGER is a TAG to +# npm, never a version (delta-5 finding 4) +# --------------------------------------------------------------------------- +if ( + SAFE_AUDIT_NO_INIT=1 + # shellcheck source=/dev/null + source "$TEST_ROOT/audit-lib.sh" >/dev/null 2>&1 + version_is_exact npm 9007199254740991.0.0 || exit 1 + ! version_is_exact npm 9007199254740992.0.0 || exit 1 + long_pre=$(printf 'a%.0s' {1..260}) + ! version_is_exact npm "1.0.0-${long_pre}" || exit 1 + # node-semver measures the 256-char ceiling on the RAW input, v included + # (delta-6 finding 4): v1.0.0- plus 249 chars = 256 raw (ok), 250 = 257. + pre_249=$(printf 'a%.0s' {1..249}) + pre_250=$(printf 'a%.0s' {1..250}) + version_is_exact npm "v1.0.0-${pre_249}" || exit 1 + ! version_is_exact npm "v1.0.0-${pre_250}" || exit 1 +); then + pass "npm version grammar enforces node-semver limits" +else + fail "npm version grammar enforces node-semver limits" +fi +prepare_case huge-core-tag +cat > "$FIXTURES/packument-hugetag.json" <<'JSON' +{ + "dist-tags": {"latest": "1.0.0", "9007199254740992.0.0": "1.0.0"}, + "versions": {"1.0.0": {}} +} +JSON +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-hugetag.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg@9007199254740992.0.0 --ecosystem npm --gate install +if expect_status 0 "an over-limit numeric spec resolves as the dist-tag npm sees"; then + pass "an over-limit numeric spec resolves as the dist-tag npm sees" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.0\.0 \(dist-tag' "npm-oracle: the tag's target version is audited"; then + pass "npm-oracle: the tag's target version is audited" +fi +prepare_case huge-core-range +cat > "$FIXTURES/packument-hugelatest.json" <<'JSON' +{ + "dist-tags": {"latest": "9007199254740992.0.0"}, + "versions": {"9007199254740992.0.0": {}, "1.0.0": {}} +} +JSON +printf '{"dependencies": {"demo-pkg": ">=1.0.0"}}\n' > "$CASE_PROJECT/package.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument-hugelatest.json" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- demo-pkg --ecosystem npm --op update --gate install +if expect_status 0 "an over-limit candidate is excluded from range selection"; then + pass "an over-limit candidate is excluded from range selection" +fi +if expect_grep "$OUT_FILE" 'Resolved:.*1\.0\.0' "npm-oracle: the valid candidate wins the range"; then + pass "npm-oracle: the valid candidate wins the range" +fi +if expect_no_grep "$OUT_FILE" '9007199254740992' "the over-limit value is never the audited target"; then + pass "the over-limit value is never the audited target" +fi + +# --------------------------------------------------------------------------- +# 51. Python find-links / extra-index env selectors reach the floor +# (delta-5 finding 3.2) +# --------------------------------------------------------------------------- +prepare_case pip-findlinks-floor +fixture="$(osv_fixture_empty)" +run_check \ + PIP_FIND_LINKS=https://evil.example/wheels \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "PIP_FIND_LINKS floors an exact python pin at WARN"; then + pass "PIP_FIND_LINKS floors an exact python pin at WARN" +fi +prepare_case uv-extra-index-floor +run_check \ + UV_EXTRA_INDEX_URL=https://evil.example/simple \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "UV_EXTRA_INDEX_URL floors an exact python pin at WARN"; then + pass "UV_EXTRA_INDEX_URL floors an exact python pin at WARN" +fi +prepare_case pip-noindex-floor +run_check \ + PIP_NO_INDEX=1 \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "PIP_NO_INDEX floors at WARN (local resolution)"; then + pass "PIP_NO_INDEX floors at WARN (local resolution)" +fi + +# --------------------------------------------------------------------------- +# 52. Inline credentials never reach output, receipts, or identities +# (delta-5 finding N2) +# --------------------------------------------------------------------------- +prepare_case creds-redacted-refusal +fixture="$(osv_fixture_empty)" +run_check \ + PIP_INDEX_URL=https://alice:sekret@evil.example/simple \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a credentialed custom index still floors"; then + pass "a credentialed custom index still floors" +fi +if expect_no_grep "$OUT_FILE" 'sekret' "credentials never reach stdout"; then + pass "credentials never reach stdout" +fi +if expect_no_grep "$ERR_FILE" 'sekret' "credentials never reach stderr"; then + pass "credentials never reach stderr" +fi +prepare_case creds-redacted-receipt +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +run_check \ + PIP_INDEX_URL=https://alice:sekret@mirror.example/simple \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 0 "trust matches the credential-free canonical endpoint"; then + pass "trust matches the credential-free canonical endpoint" +fi +if jq -e '.packages["python:requests"].source == "explicit:https://mirror.example/simple"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1 \ + && ! grep -q 'sekret' "$CASE_RUN_CONFIG/install-known.json"; then + pass "receipt identity is credential-free" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "receipt identity is credential-free" +fi + +# --------------------------------------------------------------------------- +# 53. Scoped selectors keep their KEY end-to-end: resolution uses the +# matching scope's registry, CLI beats env for the same key +# (delta-6 finding 3.2b) +# --------------------------------------------------------------------------- +prepare_case scoped-key-resolution +printf '{"install": {"trusted_registries": ["https://foo.example", "https://bar.example"]}}\n' \ + > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/registry-urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- @foo/pkg --ecosystem npm --registry '@foo:registry=https://foo.example @bar:registry=https://bar.example' --gate install +if expect_status 0 "two trusted scoped registries still gate GO"; then + pass "two trusted scoped registries still gate GO" +fi +if grep -q '^https://foo\.example/' "$CASE_DIR/registry-urls.log" \ + && ! grep -q '^https://bar\.example/' "$CASE_DIR/registry-urls.log"; then + pass "npm-oracle: the MATCHING scope's registry resolves the package" +else + cat "$CASE_DIR/registry-urls.log" >&2 + fail "npm-oracle: the MATCHING scope's registry resolves the package" +fi +prepare_case scoped-cli-over-env +printf '{"install": {"trusted_registries": ["https://cli.example", "https://env.example"]}}\n' \ + > "$CASE_RUN_CONFIG/config.json" +run_check \ + 'npm_config_@demo:registry=https://env.example' \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/registry-urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- @demo/pkg --ecosystem npm --registry '@demo:registry=https://cli.example' --gate install +if expect_status 0 "trusted scoped CLI + env gate GO"; then + pass "trusted scoped CLI + env gate GO" +fi +if grep -q '^https://cli\.example/' "$CASE_DIR/registry-urls.log"; then + pass "npm-oracle: a CLI scoped selector beats the env one" +else + cat "$CASE_DIR/registry-urls.log" >&2 + fail "npm-oracle: a CLI scoped selector beats the env one" +fi + +# --------------------------------------------------------------------------- +# 54. An env-selected alternate userconfig file reaches the floor +# (delta-6 finding 3.2b) +# --------------------------------------------------------------------------- +prepare_case env-userconfig-floor +printf 'registry=https://evil.example\n' > "$CASE_DIR/alt-npmrc" +fixture="$(osv_fixture_empty)" +run_check \ + npm_config_userconfig="$CASE_DIR/alt-npmrc" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "npm_config_userconfig registry floors an exact pin"; then + pass "npm_config_userconfig registry floors an exact pin" +fi + +# --------------------------------------------------------------------------- +# 55. Query-string credentials are redacted like userinfo; operational +# fetches keep their credentials (delta-6 finding N2) +# --------------------------------------------------------------------------- +prepare_case query-token-redacted +fixture="$(osv_fixture_empty)" +run_check \ + PIP_INDEX_URL='https://evil.example/simple?token=sekret2' \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a query-credentialed custom index still floors"; then + pass "a query-credentialed custom index still floors" +fi +if expect_no_grep "$OUT_FILE" 'sekret2' "query tokens never reach stdout" \ + && expect_no_grep "$ERR_FILE" 'sekret2' "query tokens never reach stderr"; then + pass "query tokens never reach output" +fi +prepare_case authed-registry-operational +printf '{"install": {"trusted_registries": ["https://mirror.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/registry-urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion --ecosystem npm --registry https://alice:pw@mirror.example --gate install +if expect_status 0 "an authenticated trusted registry gates GO"; then + pass "an authenticated trusted registry gates GO" +fi +if grep -q '^https://alice:pw@mirror\.example/' "$CASE_DIR/registry-urls.log"; then + pass "the packument fetch keeps its credentials (operational URL)" +else + cat "$CASE_DIR/registry-urls.log" >&2 + fail "the packument fetch keeps its credentials (operational URL)" +fi +if jq -e '.packages["npm:brace-expansion"].source == "explicit:https://mirror.example"' \ + "$CASE_RUN_CONFIG/install-known.json" >/dev/null 2>&1 \ + && ! grep -q 'alice' "$CASE_RUN_CONFIG/install-known.json"; then + pass "the receipt identity is credential-free while the fetch was authed" +else + cat "$CASE_RUN_CONFIG/install-known.json" >&2 + fail "the receipt identity is credential-free while the fetch was authed" +fi + +# --------------------------------------------------------------------------- +# 56. pip config syntax: colon delimiters, continuations, and pip's boolean +# vocabulary (delta-6 findings 3.2c and N3) +# --------------------------------------------------------------------------- +prepare_case pip-colon-config +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +find-links: https://evil.example/wheels +CONF +fixture="$(osv_fixture_empty)" +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a colon-delimited find-links floors"; then + pass "a colon-delimited find-links floors" +fi +prepare_case pip-continuation-config +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +find-links = + https://evil.example/wheels +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a continuation-line find-links floors"; then + pass "a continuation-line find-links floors" +fi +prepare_case pip-noindex-colon +cat > "$CASE_DIR/pip.conf" <<'CONF' +[install] +no-index: yes +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "no-index: yes floors"; then + pass "no-index: yes floors" +fi +prepare_case pip-noindex-false +run_check \ + PIP_NO_INDEX=False \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 0 "PIP_NO_INDEX=False preserves the default-source GO"; then + pass "PIP_NO_INDEX=False preserves the default-source GO" +fi +prepare_case pip-noindex-zero-pair +run_check \ + PIP_NO_INDEX=0 \ + UV_NO_INDEX=0 \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 0 "a 0/0 no-index pair never floors"; then + pass "a 0/0 no-index pair never floors" +fi + +# --------------------------------------------------------------------------- +# 57. Alternate npm config files are threaded as PATHS with npm's config +# tiers — scoped keys resolve per package, generic keys reach the floor +# (delta-7 finding 3.2b) +# --------------------------------------------------------------------------- +prepare_case userconfig-scoped-resolution +printf '@foo:registry=https://foo.example\n@bar:registry=https://bar.example\n' > "$CASE_DIR/alt-npmrc" +printf '{"install": {"trusted_registries": ["https://foo.example"]}}\n' > "$CASE_RUN_CONFIG/config.json" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_REGISTRY_FIXTURE="$FIXTURES/packument.json" \ + MOCK_REGISTRY_URL_LOG="$CASE_DIR/registry-urls.log" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- @foo/pkg --ecosystem npm --npm-userconfig "$CASE_DIR/alt-npmrc" --gate install +if expect_status 0 "userconfig scoped key resolves the matching scope only"; then + pass "userconfig scoped key resolves the matching scope only" +fi +if grep -q '^https://foo\.example/' "$CASE_DIR/registry-urls.log" \ + && ! grep -q 'bar\.example' "$CASE_DIR/registry-urls.log"; then + pass "npm-oracle: the matching scope's key wins from an alternate file" +else + cat "$CASE_DIR/registry-urls.log" >&2 + fail "npm-oracle: the matching scope's key wins from an alternate file" +fi +prepare_case userconfig-generic-floor +printf 'registry=https://evil.example\n' > "$CASE_DIR/alt-npmrc" +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --npm-userconfig "$CASE_DIR/alt-npmrc" --gate install +if expect_status 10 "a generic registry in an argv userconfig floors"; then + pass "a generic registry in an argv userconfig floors" +fi + +# --------------------------------------------------------------------------- +# 58. pip config semantics: [install] overrides [global] regardless of file +# order, keys normalize (case, underscores), spaced paths survive +# (delta-7 finding 3.2c) +# --------------------------------------------------------------------------- +prepare_case pip-section-precedence +cat > "$CASE_DIR/pip.conf" <<'CONF' +[install] +index-url = https://evil.example/simple + +[global] +index-url = https://pypi.org/simple +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "the install section overrides a trailing global section"; then + pass "the install section overrides a trailing global section" +fi +prepare_case pip-key-normalization +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +INDEX_URL: https://evil.example/simple +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "pip-style key normalization (case + underscores) applies"; then + pass "pip-style key normalization (case + underscores) applies" +fi +prepare_case pip-spaced-path +mkdir -p "$CASE_DIR/conf dir" +cat > "$CASE_DIR/conf dir/pip.conf" <<'CONF' +[global] +index-url = https://evil.example/simple +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/conf dir/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a config path containing spaces is still swept"; then + pass "a config path containing spaces is still swept" +fi + +# --------------------------------------------------------------------------- +# 59. pip's full boolean vocabulary: y and t are true too (delta-7 N3) +# --------------------------------------------------------------------------- +prepare_case pip-noindex-y +run_check \ + PIP_NO_INDEX=y \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "PIP_NO_INDEX=y floors"; then + pass "PIP_NO_INDEX=y floors" +fi +prepare_case pip-noindex-t-config +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +no-index: t +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a config-file no-index: t floors"; then + pass "a config-file no-index: t floors" +fi + +# --------------------------------------------------------------------------- +# 60. Option-like config paths cannot fail the sweep open: a file literally +# named "-q" is a valid config target to the managers (delta-8 3.2b/c) +# --------------------------------------------------------------------------- +prepare_case dash-userconfig +printf 'registry=https://evil.example\n' > "$CASE_PROJECT/-q" +fixture="$(osv_fixture_empty)" +run_check \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --npm-userconfig -q --gate install +if expect_status 10 "an argv userconfig named -q still floors"; then + pass "an argv userconfig named -q still floors" +fi +: > "$CASE_DIR/stderr.log" +run_check \ + NPM_CONFIG_USERCONFIG=-q \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- brace-expansion@2.1.4 --ecosystem npm --gate install +if expect_status 10 "an env userconfig named -q still floors"; then + pass "an env userconfig named -q still floors" +fi +prepare_case dash-pip-config +cat > "$CASE_PROJECT/-q" <<'CONF' +[global] +index-url = https://evil.example/simple +CONF +run_check \ + PIP_CONFIG_FILE=-q \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a PIP_CONFIG_FILE named -q still floors"; then + pass "a PIP_CONFIG_FILE named -q still floors" +fi + +# --------------------------------------------------------------------------- +# 61. ConfigParser layouts pip accepts: indented base options and a --key +# spelling (delta-8 finding 3.2c) +# --------------------------------------------------------------------------- +prepare_case pip-indented-option +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] + index-url = https://indent.example/simple +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "an indented base option is still an option, not a continuation"; then + pass "an indented base option is still an option, not a continuation" +fi +prepare_case pip-dashdash-key +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +--index-url = https://dashkey.example/simple +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "a --index-url key spelling is normalized like pip does"; then + pass "a --index-url key spelling is normalized like pip does" +fi +prepare_case pip-continuation-still-works +cat > "$CASE_DIR/pip.conf" <<'CONF' +[global] +find-links = + https://evil.example/wheels +CONF +run_check \ + PIP_CONFIG_FILE="$CASE_DIR/pip.conf" \ + MOCK_OSV_FIXTURE="$fixture" \ + MOCK_SOCKET_MODE=ok \ + -- requests@2.32.0 --ecosystem python --gate install +if expect_status 10 "deeper-indent continuations still attach to their option"; then + pass "deeper-indent continuations still attach to their option" +fi + +# --------------------------------------------------------------------------- +printf '\n%d passed, %d failed\n' "$PASS_COUNT" "$FAIL_COUNT" +[[ "$FAIL_COUNT" -eq 0 ]] diff --git a/tests/install/run.sh b/tests/install/run.sh index cc4c430..a39aa78 100755 --- a/tests/install/run.sh +++ b/tests/install/run.sh @@ -46,6 +46,12 @@ write_safe_audit_stub() { cat > "${bin_dir}/safe-audit" <<'STUB' #!/usr/bin/env bash +# effective-sources is local-only plumbing the stale readers depend on: +# forward it to the REAL implementation so the tests exercise the real +# source-identity derivation (delta-4 findings 3.2/N1). +if [[ "${1:-}" == "effective-sources" ]]; then + exec "${ROOT_DIR}/bin/safe-audit" "$@" +fi { printf 'AUDIT' for arg in "$@"; do @@ -203,8 +209,11 @@ assert_err_not_contains_fragment() { } # Simulates a harness shell snapshot that keeps the public wrapper functions -# but strips all safe_install_* helpers (the Claude Code silent-127 regression). -STRIP_HELPERS='for f in ${(k)functions}; do [[ "$f" == safe_install_* ]] && unfunction "$f"; done; ' +# but strips all safe_install_* helpers (the Claude Code silent-127 +# regression) AND the *_impl helpers behind the public wrappers — a public- +# only snapshot must refuse legibly, never die 127 mid-function or leak +# command-not-found noise (delta-9 finding N4). +STRIP_HELPERS='for f in ${(k)functions}; do [[ "$f" == safe_install_* || "$f" == *_impl ]] && unfunction "$f"; done; ' case_degraded_install_blocks_legibly() { prepare_case "degraded-install-blocks-legibly" @@ -213,6 +222,7 @@ case_degraded_install_blocks_legibly() { assert_err_contains_fragment 'safe: BLOCKED npm install' "$FUNCNAME" || return assert_err_contains_fragment 'safe explain' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -222,6 +232,7 @@ case_degraded_exec_blocks_legibly() { assert_status 100 "$FUNCNAME" || return assert_err_contains_fragment 'safe: BLOCKED pnpm dlx' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -238,6 +249,7 @@ case_degraded_alias_subcommands_block() { assert_err_contains_fragment 'safe: BLOCKED yarn dlx' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tyarn' "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -248,6 +260,7 @@ case_degraded_non_install_passes_through() { assert_log_contains $'REAL\tnpm\t--version' "$FUNCNAME" || return assert_log_contains $'REAL\tvolta\tlist\tnode' "$FUNCNAME" || return assert_log_contains $'REAL\tcomposer\tvalidate' "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -266,6 +279,7 @@ case_degraded_leading_flag_still_blocks() { assert_status 100 "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT="${STRIP_HELPERS}go -C dir install evil@v1" run_zsh assert_status 100 "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -279,6 +293,7 @@ case_degraded_leading_flag_benign_passes() { SAFE_INSTALL_TEST_SCRIPT="${STRIP_HELPERS}go get example.com/x@v1" run_zsh assert_status 0 "$FUNCNAME" || return assert_log_contains $'REAL\tgo\tget\texample.com/x@v1' "$FUNCNAME" || return + assert_err_not_contains_fragment 'command not found' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -296,7 +311,7 @@ case_npm_exec_fetch_audits() { prepare_case "npm-exec-fetch-audits" SAFE_INSTALL_TEST_SCRIPT='npm exec create-foo' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tcreate-foo@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tcreate-foo\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\texec\tcreate-foo' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -323,11 +338,11 @@ case_npm_exec_versioned_spec_audits_despite_local_bin() { # local bin, so it must be audited (review High 1). SAFE_INSTALL_TEST_SCRIPT='npm exec blockme@latest' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='bun x blockme@1.2.3' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@1.2.3\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme@1.2.3\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -360,7 +375,7 @@ case_npm_exec_parent_walk_resists_shadowing() { npm exec blockme ' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -371,7 +386,7 @@ case_leading_global_flag_gates_npm_install() { # flag, so `install` slipped the gate. =form and space-form both gate now. SAFE_INSTALL_TEST_SCRIPT='npm --loglevel=error install -g blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='npm --loglevel error install -g blockme' run_zsh assert_status 104 "$FUNCNAME" || return @@ -382,7 +397,7 @@ case_leading_global_flag_gates_pnpm_dlx() { prepare_case "leading-global-flag-gates-pnpm-dlx" SAFE_INSTALL_TEST_SCRIPT='pnpm --filter=web dlx blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -392,7 +407,7 @@ case_leading_global_flag_gates_yarn_add() { # The verbatim inbox shape: yarn --cwd add . SAFE_INSTALL_TEST_SCRIPT='yarn --cwd sub add blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tyarn' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -401,7 +416,7 @@ case_leading_global_flag_gates_uv_tool_run() { prepare_case "leading-global-flag-gates-uv-tool-run" SAFE_INSTALL_TEST_SCRIPT='uv --offline tool run blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -413,7 +428,7 @@ case_value_flag_consumes_its_value() { # value; the resolver must not stop on it. SAFE_INSTALL_TEST_SCRIPT='npm --registry https://r.example install -g blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--registry\thttps://r.example' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -435,7 +450,7 @@ case_unknown_leading_flag_eqform_escapes() { # real install is still audited. SAFE_INSTALL_TEST_SCRIPT='npm --totally-unknown-flag=value install -g blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -456,13 +471,13 @@ case_optional_boolean_explicit_value_gates() { # subcommand is still found and gated (review round 1 High 1/High 3). SAFE_INSTALL_TEST_SCRIPT='npm --global false install blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='npm --workspaces false install blockme' run_zsh assert_status 104 "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='pnpm --recursive false add blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -472,7 +487,7 @@ case_optional_boolean_without_value_still_gates() { # boolean flag and must not be consumed as a value. SAFE_INSTALL_TEST_SCRIPT='npm --global install blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -482,7 +497,7 @@ case_pnpm_config_switch_not_value() { # flag ate the `add` subcommand and bypassed the gate (review round 1 High 2). SAFE_INSTALL_TEST_SCRIPT='pnpm --config add blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -493,7 +508,7 @@ case_pip_use_feature_takes_value() { # value as the subcommand and bypassed the gate (review round 1 High 4). SAFE_INSTALL_TEST_SCRIPT='pip --use-feature fast-deps install blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpip' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -509,7 +524,7 @@ case_bun_equals_only_flag_not_space_value() { assert_log_not_contains_fragment $'REAL\tbun' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='bun --config=bunfig.toml add blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -517,7 +532,7 @@ case_npm_exec_package_flag_blocks() { prepare_case "npm-exec-package-flag-blocks" SAFE_INSTALL_TEST_SCRIPT='npm exec --package=blockme -- create' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -528,16 +543,16 @@ case_exec_value_flag_does_not_hide_package() { # package; the real fetched package is audited (review High 2). SAFE_INSTALL_TEST_SCRIPT='npm exec --cache /tmp/cache-ok blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='pnpm dlx --allow-build ok-builder blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return # =form is unambiguous and must not over-refuse. SAFE_INSTALL_TEST_SCRIPT='npm exec --cache=/tmp/cache-ok cowsay' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tcowsay@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tcowsay\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -559,11 +574,11 @@ case_exec_package_selector_flags() { # provides and must not shadow it (re-review High 1/2). SAFE_INSTALL_TEST_SCRIPT='pnpm dlx --package blockme benign' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='bun x --package=blockme benign' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tbun' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -574,16 +589,16 @@ case_exec_boolean_flags_do_not_eat_package() { # (re-review High 1/3): the package still audits. SAFE_INSTALL_TEST_SCRIPT='pnpm dlx -c blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tpnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='yarn dlx -q blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tyarn' "$FUNCNAME" || return # Documented booleans pass through without over-refusing (re-review Med 1). SAFE_INSTALL_TEST_SCRIPT='npm exec --workspaces cowsay' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tcowsay@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tcowsay\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='bun x --verbose cowsay' run_zsh assert_status 0 "$FUNCNAME" || return pass "$FUNCNAME" @@ -595,7 +610,7 @@ case_npm_exec_post_positional_package() { # the fetched package (round-3 High 1). SAFE_INSTALL_TEST_SCRIPT='npm exec benign --package blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='npm exec benign --package=blockme' run_zsh assert_status 104 "$FUNCNAME" || return @@ -618,7 +633,7 @@ case_npm_exec_short_p_not_greedy_post_command() { # must not shadow the actual fetched positional package (round-4 High 1). SAFE_INSTALL_TEST_SCRIPT='npm exec blockme -p okpkg' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return # -p before the command IS a selector (audited); okpkg is the command. SAFE_INSTALL_TEST_SCRIPT='npm exec -p blockme okpkg' run_zsh @@ -662,11 +677,11 @@ case_dlx_and_x_audit() { prepare_case "dlx-and-x-audit" SAFE_INSTALL_TEST_SCRIPT='pnpm dlx cowsay@1.6.0' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tcowsay@1.6.0\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tcowsay@1.6.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tpnpm\tdlx\tcowsay@1.6.0' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='bun x cowsay' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tcowsay@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tcowsay\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tbun\tx\tcowsay' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='yarn dlx blockme' run_zsh assert_status 104 "$FUNCNAME" || return @@ -678,14 +693,14 @@ case_uv_run_and_tool_run_gate() { prepare_case "uv-run-and-tool-run-gate" SAFE_INSTALL_TEST_SCRIPT='uv run --with warnme script.py' run_zsh assert_status 100 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\twarnme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\twarnme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='uv run script.py' run_zsh assert_status 0 "$FUNCNAME" || return assert_log_contains $'REAL\tuv\trun\tscript.py' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='uv tool run ruff' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\truff@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\truff\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='uv tool run --from blockme r' run_zsh assert_status 104 "$FUNCNAME" || return pass "$FUNCNAME" @@ -696,12 +711,12 @@ case_uv_short_with_and_boundary() { # -w is the short --with and must be audited (review High 3). SAFE_INSTALL_TEST_SCRIPT='uv run -w blockme script.py' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return # A value flag before --with must not shift the boundary; --with rich audits. SAFE_INSTALL_TEST_SCRIPT='uv run --python 3.12 --with rich pytest' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\trich@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\trich\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tuv\trun\t--python\t3.12\t--with\trich\tpytest' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -723,7 +738,7 @@ case_uv_tool_run_short_with_keeps_tool() { # (review High 4). SAFE_INSTALL_TEST_SCRIPT='uv tool run -w ok-extra blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -734,13 +749,13 @@ case_uv_tool_run_with_extra_is_audited() { # tool (re-review High 4). SAFE_INSTALL_TEST_SCRIPT='uv tool run -w blockme ruff' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return # Both clean → audited and delegated. SAFE_INSTALL_TEST_SCRIPT='uv tool run --with ok ruff' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tok@latest\t--ecosystem\tpython' "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\truff@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tok\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\truff\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -750,7 +765,7 @@ case_uv_run_value_flag_before_with() { # scan early and miss the later --with fetch (re-review High 5). SAFE_INSTALL_TEST_SCRIPT='uv run --no-extra dev --with blockme python' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return # -m is a switch; it must not fail closed (re-review Med 1). SAFE_INSTALL_TEST_SCRIPT='uv run -m pytest' run_zsh @@ -764,7 +779,7 @@ case_go_run_module_gates() { write_tool_stub "${BIN_DIR}" go SAFE_INSTALL_TEST_SCRIPT='go run example.com/blockme@v1.0.0' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\texample.com/blockme@v1.0.0\t--ecosystem\tgo' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\texample.com/blockme@v1.0.0\t--ecosystem\tgo\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tgo' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='go run ./cmd' run_zsh assert_status 0 "$FUNCNAME" || return @@ -780,7 +795,7 @@ case_go_run_value_flag_does_not_hide_module() { # (round-5 High): -C/-mod take values, then the module@version still audits. SAFE_INSTALL_TEST_SCRIPT='go run -C /tmp example.com/blockme@v1.0.0' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\texample.com/blockme@v1.0.0\t--ecosystem\tgo' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\texample.com/blockme@v1.0.0\t--ecosystem\tgo\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tgo' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='go run -mod mod example.com/blockme@v1.0.0' run_zsh assert_status 104 "$FUNCNAME" || return @@ -802,17 +817,17 @@ case_update_family_gates() { SAFE_INSTALL_TEST_SCRIPT='npm update lodash' run_zsh assert_status 0 "$FUNCNAME" || return assert_log_contains $'AUDIT\tscan\t--project\t.' "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tlodash@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tlodash\t--ecosystem\tnpm\t--gate\tinstall\t--op\tupdate' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\tupdate\tlodash' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='yarn upgrade left-pad' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tleft-pad@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tleft-pad\t--ecosystem\tnpm\t--gate\tinstall\t--op\tupdate' "$FUNCNAME" || return SAFE_INSTALL_TEST_SCRIPT='npm u blockme' run_zsh assert_status 104 "$FUNCNAME" || return # npm ships `udpate` as a real alias of update (review Medium 1). SAFE_INSTALL_TEST_SCRIPT='npm udpate blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tupdate' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -854,7 +869,7 @@ case_global_package_check() { prepare_case "global-package-check" SAFE_INSTALL_TEST_SCRIPT='npm install -g left-pad@1.3.0' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tleft-pad@1.3.0\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tleft-pad@1.3.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\tinstall\t-g\tleft-pad@1.3.0' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -875,7 +890,7 @@ case_add_scans_and_checks() { SAFE_INSTALL_TEST_SCRIPT='pnpm add --filter web --workspace-root lodash' run_zsh assert_status 0 "$FUNCNAME" || return assert_log_contains $'AUDIT\tscan\t--project\t.' "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tlodash@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tlodash\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'\tweb\t--ecosystem' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -884,7 +899,7 @@ case_blocked_install() { prepare_case "blocked-install" SAFE_INSTALL_TEST_SCRIPT='npm install -g blockme' run_zsh assert_status 104 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tblockme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -893,7 +908,7 @@ case_warning_install_blocks() { prepare_case "warning-install-blocks" SAFE_INSTALL_TEST_SCRIPT='npm install -g warnme' run_zsh assert_status 100 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\twarnme@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\twarnme\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -906,7 +921,7 @@ case_host_allow_warn_allows_exact_global_install() { JSON SAFE_INSTALL_TEST_SCRIPT='npm install -g warnme@1.0.0' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\twarnme@1.0.0\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\twarnme@1.0.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\tinstall\t-g\twarnme@1.0.0' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -919,7 +934,7 @@ case_host_allow_warn_requires_exact_version() { JSON SAFE_INSTALL_TEST_SCRIPT='npm install -g warnme@1.0.1' run_zsh assert_status 100 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\twarnme@1.0.1\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\twarnme@1.0.1\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -928,7 +943,7 @@ case_npm_colon_version_global_install() { prepare_case "npm-colon-version-global-install" SAFE_INSTALL_TEST_SCRIPT='npm install -g @qwen-code/qwen-code:0.16.2' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\t@qwen-code/qwen-code@0.16.2\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\t@qwen-code/qwen-code@0.16.2\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\tinstall\t-g\t@qwen-code/qwen-code:0.16.2' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -937,7 +952,7 @@ case_npm_alias_audits_target_package() { prepare_case "npm-alias-audits-target-package" SAFE_INSTALL_TEST_SCRIPT='npm install -g qwen@npm:@qwen-code/qwen-code@0.16.2' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\t@qwen-code/qwen-code@0.16.2\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\t@qwen-code/qwen-code@0.16.2\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_contains $'REAL\tnpm\tinstall\t-g\tqwen@npm:@qwen-code/qwen-code@0.16.2' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -946,7 +961,7 @@ case_audit_failure_blocks() { prepare_case "audit-failure-blocks" SAFE_AUDIT_CHECK_STATUS=42 SAFE_INSTALL_TEST_SCRIPT='uv tool install repomix' run_zsh assert_status 100 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\trepomix@latest\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\trepomix\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'REAL\tuv' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -986,7 +1001,7 @@ case_npm_complex_flags() { prepare_case "npm-complex-flags" SAFE_INSTALL_TEST_SCRIPT='npm install --registry https://registry.example --tag beta --omit dev left-pad' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tleft-pad@latest\t--ecosystem\tnpm' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tleft-pad\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--dist-tag\tbeta\t--registry\thttps://registry.example' "$FUNCNAME" || return assert_log_not_contains_fragment $'\thttps://registry.example@latest' "$FUNCNAME" || return assert_log_not_contains_fragment $'\tbeta@latest' "$FUNCNAME" || return assert_log_not_contains_fragment $'\tdev@latest' "$FUNCNAME" || return @@ -998,7 +1013,7 @@ case_pip_complex_flags() { prepare_case "pip-complex-flags" SAFE_INSTALL_TEST_SCRIPT='pip install --index-url https://pypi.example --constraint constraints.txt requests==2.32.0' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\trequests@2.32.0\t--ecosystem\tpython' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\trequests@2.32.0\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall\t--registry\thttps://pypi.example' "$FUNCNAME" || return assert_log_not_contains_fragment $'\thttps://pypi.example@latest' "$FUNCNAME" || return assert_log_not_contains_fragment $'\tconstraints.txt@latest' "$FUNCNAME" || return pass "$FUNCNAME" @@ -1017,7 +1032,7 @@ case_cargo_parser() { prepare_case "cargo-parser" SAFE_INSTALL_TEST_SCRIPT='cargo install ripgrep --version 14.1.1' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tripgrep@latest\t--ecosystem\tcargo' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tripgrep@14.1.1\t--ecosystem\tcargo\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'\t14.1.1@latest' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -1026,7 +1041,7 @@ case_go_parser() { prepare_case "go-parser" SAFE_INSTALL_TEST_SCRIPT='go install -tags netgo example.com/tool@v1.2.3' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\texample.com/tool@v1.2.3\t--ecosystem\tgo' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\texample.com/tool@v1.2.3\t--ecosystem\tgo\t--gate\tinstall\t--op\tinstall' "$FUNCNAME" || return assert_log_not_contains_fragment $'\tnetgo@latest' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -1035,7 +1050,7 @@ case_composer_parser() { prepare_case "composer-parser" SAFE_INSTALL_TEST_SCRIPT='composer global require --working-dir /tmp vendor/pkg:^1' run_zsh assert_status 0 "$FUNCNAME" || return - assert_log_contains $'AUDIT\tcheck\tvendor/pkg@^1\t--ecosystem\tcomposer' "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tvendor/pkg@^1\t--ecosystem\tcomposer\t--gate\tinstall\t--op\tinstall\t--project-dir\t/tmp' "$FUNCNAME" || return assert_log_not_contains_fragment $'\t/tmp@latest' "$FUNCNAME" || return pass "$FUNCNAME" } @@ -1228,6 +1243,127 @@ EOF pass "$FUNCNAME" } +case_pip_cumulative_sources_all_reach_audit() { + prepare_case "pip-cumulative-sources" + # pip considers BOTH --find-links and the index; a later trusted selector + # must not erase the earlier untrusted one (delta-2 finding 3). + # find-links records its actual endpoint (not a blanket sentinel) so an + # operator can trust the specific location (delta-5 finding 3.2). + SAFE_INSTALL_TEST_SCRIPT='pip install --find-links https://evil.example/wheels --index-url https://pypi.org/simple blockme==1.0.0' run_zsh + assert_status 104 "$FUNCNAME" || return + assert_log_contains $'AUDIT\tcheck\tblockme@1.0.0\t--ecosystem\tpython\t--gate\tinstall\t--op\tinstall\t--registry\thttps://evil.example/wheels https://pypi.org/simple' "$FUNCNAME" || return + pass "$FUNCNAME" +} + +case_npm_scoped_and_userconfig_sources_reach_audit() { + prepare_case "npm-scoped-userconfig-sources" + # Raw scoped registry flags are source selectors npm honors on the + # command line; --userconfig swaps in a whole config file whose registry + # keys become effective (delta-5 finding 3.2). + # The scoped selector keeps its KEY: flattening the bare URL let the + # resolver pick a different scope's registry (delta-6 finding 3.2b). + SAFE_INSTALL_TEST_SCRIPT='npm install --@demo:registry=https://scoped.example @demo/pkg@1.2.3' run_zsh + assert_log_contains $'AUDIT\tcheck\t@demo/pkg@1.2.3\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--registry\t@demo:registry=https://scoped.example' "$FUNCNAME" || return + + # The prev-form scoped flag consumes its value: without that, the URL is + # parsed as an extra PACKAGE and audited (delta-7 finding 3.2b). + : > "${LOG_FILE}" + SAFE_INSTALL_TEST_SCRIPT='npm install --@foo:registry https://foo.example @foo/pkg@1.2.3' run_zsh + assert_log_contains $'AUDIT\tcheck\t@foo/pkg@1.2.3\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--registry\t@foo:registry=https://foo.example' "$FUNCNAME" || return + assert_log_not_contains_fragment $'check\thttps://foo.example' "$FUNCNAME" || return + + # Alternate config files thread as PATHS — value extraction lost key and + # precedence semantics and copied credentials into argv (delta-7 findings + # 3.2b/N2). + : > "${LOG_FILE}" + printf 'registry=https://userconf.example/\n@demo:registry=https://scopedconf.example\n' > "${WORK_DIR}/alt-npmrc" + SAFE_INSTALL_TEST_SCRIPT='npm install --userconfig alt-npmrc okpkg@1.0.0' run_zsh + assert_log_contains $'AUDIT\tcheck\tokpkg@1.0.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--npm-userconfig\talt-npmrc' "$FUNCNAME" || return + assert_log_not_contains_fragment 'userconf.example' "$FUNCNAME" || return + pass "$FUNCNAME" +} + +case_wrapper_state_cleared_after_gate() { + prepare_case "wrapper-state-cleared" + # A long-lived interactive shell must not retain the scanned source set + # (possibly credential-bearing) after the gate decision (delta-7 N2). + SAFE_INSTALL_TEST_SCRIPT='npm install --registry https://alice:sekret@mirror.example okpkg@1.0.0; print -r -u2 -- "POST_REG=[${SAFE_INSTALL_REGISTRY:-}][${SAFE_INSTALL_NPM_USERCONFIG:-}]"' run_zsh + assert_err_contains_fragment 'POST_REG=[][]' "$FUNCNAME" || return + + # The PROJECT-install route (no positional packages) scans too and must + # clear equally — cleanup tied to check_many missed it (delta-8 N2). + SAFE_INSTALL_TEST_SCRIPT='npm install --registry https://alice:sekret@mirror.example; print -r -u2 -- "POST_PROJ=[${SAFE_INSTALL_REGISTRY:-}]"' run_zsh + assert_err_contains_fragment 'POST_PROJ=[]' "$FUNCNAME" || return + pass "$FUNCNAME" +} + +case_source_credentials_preserved_for_audit() { + prepare_case "source-credentials-operational" + # The accumulated set is OPERATIONAL: safe-audit needs the credentials to + # fetch packuments from authenticated registries — pre-redacting here + # broke those installs (delta-6 finding N2). Redaction happens centrally + # in safe-audit at every identity/receipt/display sink (covered in the + # audit suite). + SAFE_INSTALL_TEST_SCRIPT='npm install --registry https://alice:sekret@mirror.example/ okpkg@1.0.0' run_zsh + assert_log_contains $'AUDIT\tcheck\tokpkg@1.0.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--registry\thttps://alice:sekret@mirror.example' "$FUNCNAME" || return + pass "$FUNCNAME" +} + +case_npm_repeated_registry_keeps_true_last() { + prepare_case "npm-repeated-registry-last" + # npm is last-wins: A B A means npm installs from A. Deduplication must + # move the repeat to the END so the resolver's last-word read matches + # npm's choice (delta-4 finding 3.1). + SAFE_INSTALL_TEST_SCRIPT='npm install -g --registry https://a.example --registry https://b.example --registry https://a.example okpkg@1.0.0' run_zsh + assert_log_contains $'AUDIT\tcheck\tokpkg@1.0.0\t--ecosystem\tnpm\t--gate\tinstall\t--op\tinstall\t--registry\thttps://b.example https://a.example' "$FUNCNAME" || return + pass "$FUNCNAME" +} + +case_stale_evidence_is_source_scoped() { + prepare_case "stale-evidence-source-scoped" + mkdir -p "${HOME_DIR}/.config/safe/run" + printf '{"packages": {"npm:okpkg": {"version": "1.0.0", "verdict": "GO", "reasons": [], "evidence": "x", "source": "implicit-default", "first_allowed": "%s", "last_used": "2026-07-31", "times_used": 1}}}\n' \ + "$(date -Iseconds)" > "${HOME_DIR}/.config/safe/run/install-known.json" + + # Same source (implicit default): the fresh GO receipt may carry an audit + # timeout. + SAFE_AUDIT_CHECK_STATUS=124 SAFE_INSTALL_TEST_SCRIPT='npm install -g okpkg@1.0.0' run_zsh + assert_status 0 "$FUNCNAME" || return + assert_log_contains $'REAL\tnpm\tinstall\t-g\tokpkg@1.0.0' "$FUNCNAME" || return + assert_err_contains_fragment 'stale evidence' "$FUNCNAME" || return + + # Different source: default-registry evidence must NOT vouch for a custom + # index (delta-2 finding 5) — fail closed. + : > "${LOG_FILE}" + SAFE_AUDIT_CHECK_STATUS=124 SAFE_INSTALL_TEST_SCRIPT='npm install -g --registry https://evil.example okpkg@1.0.0' run_zsh + assert_status 100 "$FUNCNAME" || return + assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return + + # A literal selector spelled "default" is an EXPLICIT source: it must not + # collide with the implicit-default sentinel (delta-3 finding 5). + : > "${LOG_FILE}" + SAFE_AUDIT_CHECK_STATUS=124 SAFE_INSTALL_TEST_SCRIPT='npm install -g --registry default okpkg@1.0.0' run_zsh + assert_status 100 "$FUNCNAME" || return + assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return + + # An env-configured registry is part of the effective source even with no + # argv selector: implicit-default evidence must not vouch for it (delta-3 + # finding 3.2). + : > "${LOG_FILE}" + SAFE_AUDIT_CHECK_STATUS=124 SAFE_INSTALL_TEST_SCRIPT='NPM_CONFIG_REGISTRY=https://evil.example npm install -g okpkg@1.0.0' run_zsh + assert_status 100 "$FUNCNAME" || return + assert_log_not_contains_fragment $'REAL\tnpm' "$FUNCNAME" || return + + # Legacy source-less receipts map to implicit default only. + printf '{"packages": {"npm:okpkg": {"version": "1.0.0", "verdict": "GO", "reasons": [], "evidence": "x", "first_allowed": "%s", "last_used": "2026-07-31", "times_used": 1}}}\n' \ + "$(date -Iseconds)" > "${HOME_DIR}/.config/safe/run/install-known.json" + : > "${LOG_FILE}" + SAFE_AUDIT_CHECK_STATUS=124 SAFE_INSTALL_TEST_SCRIPT='npm install -g okpkg@1.0.0' run_zsh + assert_status 0 "$FUNCNAME" || return + assert_log_contains $'REAL\tnpm\tinstall\t-g\tokpkg@1.0.0' "$FUNCNAME" || return + pass "$FUNCNAME" +} + case_uninstall_cleans_shell_and_legacy_binaries() { prepare_case "uninstall-cleans-shell-and-legacy-binaries" mkdir -p "${HOME_DIR}/.local/bin" "${HOME_DIR}/.local/share/zsh/site-functions" "${HOME_DIR}/.config/safe-run/completions" "${HOME_DIR}/.config/safe" "${HOME_DIR}/.local/share/safe" @@ -1339,6 +1475,12 @@ main() { case_cargo_parser \ case_go_parser \ case_composer_parser \ + case_pip_cumulative_sources_all_reach_audit \ + case_npm_scoped_and_userconfig_sources_reach_audit \ + case_source_credentials_preserved_for_audit \ + case_wrapper_state_cleared_after_gate \ + case_npm_repeated_registry_keeps_true_last \ + case_stale_evidence_is_source_scoped \ case_install_idempotent_no_wrappers \ case_install_idempotent_with_completions \ case_install_cleans_legacy_safe_install_artifacts \