Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 91 additions & 22 deletions .github/workflows/dependency-cooldown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,60 @@ jobs:
)
# --- END inline:scripts/diff-touches-lockfile.sh ---

# --- BEGIN inline:scripts/classify-touched-paths.sh ---
classify_touched_paths() (
# classify-touched-paths.sh — filter dependency-relevant paths down to
# those that extract-deps.sh does NOT parse.
#
# Input: newline-delimited paths on stdin (typically the output of
# diff-touches-lockfile.sh).
# Output: subset of input paths whose filename/location is NOT in the
# extract-deps.sh supported set, sorted & deduped, one per line.
# Empty stdout when every input path is supported.
# Exit: 0 on success (zero or more output rows). No exit-2 path: malformed
# input here is "an unrecognised filename" which is exactly what we
# report on stdout.
#
# Supported set (must stay in sync with scripts/extract-deps.sh):
# - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser)
# - uv.lock | poetry.lock (TOML [[package]] stanza parser)
# - requirements*.txt (pip line-shape parser)
#
# `requirements*.txt` is supported by line-shape parsing, not by a full
# requirements-file parser. The extractor recognizes added requirement lines
# with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix;
# comments, includes like `-r other.txt`, hash/check option lines, and other
# requirements-file structure are ignored. Standard Dependabot bumps reliably
# surface as added pinned requirement lines, which is what we promise.
#
# Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile,
# Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json,
# package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is
# unsupported and printed to stdout. Layer 2 (PR-body fallback) may still
# recover deps from some of these for the scan loop, but the guard fires
# because the diff parser cannot prove the scan was complete.

set -euo pipefail

while IFS= read -r path || [ -n "$path" ]; do
[ -z "$path" ] && continue
base="${path##*/}"

# Supported (drop from output).
case "$path" in
.github/workflows/*.yml|.github/workflows/*.yaml) continue ;;
esac
case "$base" in
uv.lock|poetry.lock) continue ;;
requirements*.txt) continue ;;
esac

# Anything else: emit as unsupported.
printf '%s\n' "$path"
done | sort -u
)
# --- END inline:scripts/classify-touched-paths.sh ---

# --- BEGIN inline:scripts/pr-body-to-deps.sh ---
pr_body_to_deps() (
# pr-body-to-deps.sh — extract Dependabot dep bumps from a PR body, emit TSV.
Expand Down Expand Up @@ -492,18 +546,19 @@ jobs:
# --- Extract dependencies via standalone script (Task 8) ---
DEPS_TSV=$(echo "$DIFF" | extract_deps)

# --- Compute touched paths and unsupported subset ONCE, up front. ---
# Both Layer 2 (PR-body fallback) and Layer 3 (fail-loud guard) consume
# these. Hoisted from inside Layer 2 to keep the two layers' inputs
# identical and to make the guard logic independent of DEPS_TSV being
# empty (fix for issue #62 — partial extraction silent-green).
TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)
UNSUPPORTED_PATHS=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true)

# --- Layer 2: PR-body fallback (defense in depth for issue #52) ---
TOUCHED_PATHS=""
# Routing list unchanged — pyproject.toml and Pipfile remain pypi-routable
# here even though Layer 3 will still fire on them, since the guard
# reflects extract-deps parser support, not PR-body recovery support.
if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then
TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)
# Keep the pypi regex below in sync with filename_to_ecosystem() in
# scripts/extract-deps.sh — ONLY lockfiles the parser actually handles
# should route to the pypi fallback. Cargo.lock, yarn.lock, etc. are
# emitted by diff_touches_lockfile (for guard detection) but must NOT
# route here — they fall through and trip the fail-loud guard instead.
# Mixed-touch policy: if a PR touches BOTH a pypi lockfile AND an actions
# workflow YAML, pypi wins. The fallback parser handles one ecosystem per
# PR; actions bumps (if any) fall through to the guard and will refuse green.
ECO_HINT=""
if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then
ECO_HINT="pypi"
Expand All @@ -517,17 +572,21 @@ jobs:
fi
fi

# --- Layer 3: Fail-loud guard (fix for issue #52) ---
# --- Layer 3: Fail-loud guard (fixes for issues #52 and #62) ---
# Two composed conditions:
# (a) Unsupported paths touched: scan cannot be proven complete (issue #62).
# (b) All touched paths supported but extraction yielded zero rows: parser
# miss on a supported file (issue #52).
# Unsupported takes message precedence — it's the more specific diagnosis.
GUARD_TRIGGERED=false
if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then
# Defense-in-depth: Layer 2 always sets TOUCHED_PATHS when it runs, but the
# ${:-} default guards against future refactors that might skip Layer 2.
TOUCHED_PATHS="${TOUCHED_PATHS:-$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)}"
if [ -n "$TOUCHED_PATHS" ]; then
GUARD_TRIGGERED=true
TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge."
fi
if [ -n "$UNSUPPORTED_PATHS" ]; then
GUARD_TRIGGERED=true
UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these."
elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$TOUCHED_PATHS" ]; then
GUARD_TRIGGERED=true
TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge."
fi

# Populate ACTIONS, PY_DEPS, ACTION_VERSIONS, PY_VERSIONS from DEPS_TSV
Expand Down Expand Up @@ -890,7 +949,11 @@ jobs:
# needs to agree (rather than claim "No known exploits" + auto-merge
# enabled on a run we refused to scan). The EXTRACTION_WARNING_SECTION
# prepended later (~line 960) still carries the detailed path list.
if [ "$GUARD_TRIGGERED" = "true" ]; then
if [ "$GUARD_TRIGGERED" = "true" ] && [ "$TOTAL" -eq 0 ]; then
# Extraction failed AND no advisories from any subset that did get scanned.
# With the issue #62 fix the guard can ALSO fire on a partial-extraction PR
# where DEPS_TSV is non-empty and scans found advisories — that case falls
# through to the TOTAL>0 branch so the actual advisory IDs are preserved.
RESULTS_HEADER="Dependency extraction failed — manual review required."
TABLE_HDR="| Source | Vulnerabilities |"
TABLE_SEP="|--------|----------------|"
Expand All @@ -903,7 +966,13 @@ jobs:
TABLE_HDR="| ID | Severity | Summary | Source |"
TABLE_SEP="|----|----------|---------|--------|"
RESULTS_TABLE="$(printf '%s\n%s\n%s' "$TABLE_HDR" "$TABLE_SEP" "$SCAN_RESULTS")"
RESULTS_FOOTER="> Review the advisories above before deciding to merge."
if [ "$GUARD_TRIGGERED" = "true" ]; then
# Partial extraction: scans of the supported subset DID surface advisories.
# Cross-reference so the partial-coverage signal isn't lost.
RESULTS_FOOTER="> Review the advisories above before deciding to merge."$'\n'"> Note: part of the diff could not be scanned (parser does not support all touched files). Manual review required."
else
RESULTS_FOOTER="> Review the advisories above before deciding to merge."
fi
elif [ -n "$HAS_ERROR" ]; then
gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \
-f state=error \
Expand Down
112 changes: 90 additions & 22 deletions .github/workflows/dependency-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,60 @@ jobs:
)
# --- END inline:scripts/diff-touches-lockfile.sh ---

# --- BEGIN inline:scripts/classify-touched-paths.sh ---
classify_touched_paths() (
# classify-touched-paths.sh — filter dependency-relevant paths down to
# those that extract-deps.sh does NOT parse.
#
# Input: newline-delimited paths on stdin (typically the output of
# diff-touches-lockfile.sh).
# Output: subset of input paths whose filename/location is NOT in the
# extract-deps.sh supported set, sorted & deduped, one per line.
# Empty stdout when every input path is supported.
# Exit: 0 on success (zero or more output rows). No exit-2 path: malformed
# input here is "an unrecognised filename" which is exactly what we
# report on stdout.
#
# Supported set (must stay in sync with scripts/extract-deps.sh):
# - .github/workflows/*.yml | *.yaml (GitHub Actions `uses:` line parser)
# - uv.lock | poetry.lock (TOML [[package]] stanza parser)
# - requirements*.txt (pip line-shape parser)
#
# `requirements*.txt` is supported by line-shape parsing, not by a full
# requirements-file parser. The extractor recognizes added requirement lines
# with operators ==, >=, <=, ~=, !=, >, or < and a numeric version prefix;
# comments, includes like `-r other.txt`, hash/check option lines, and other
# requirements-file structure are ignored. Standard Dependabot bumps reliably
# surface as added pinned requirement lines, which is what we promise.
#
# Everything else diff-touches-lockfile.sh emits — pyproject.toml, Pipfile,
# Pipfile.lock, go.mod, Cargo.toml, Cargo.lock, package.json,
# package-lock.json, yarn.lock, pnpm-lock.yaml, and any other *.lock — is
# unsupported and printed to stdout. Layer 2 (PR-body fallback) may still
# recover deps from some of these for the scan loop, but the guard fires
# because the diff parser cannot prove the scan was complete.

set -euo pipefail

while IFS= read -r path || [ -n "$path" ]; do
[ -z "$path" ] && continue
base="${path##*/}"

# Supported (drop from output).
case "$path" in
.github/workflows/*.yml|.github/workflows/*.yaml) continue ;;
esac
case "$base" in
uv.lock|poetry.lock) continue ;;
requirements*.txt) continue ;;
esac

# Anything else: emit as unsupported.
printf '%s\n' "$path"
done | sort -u
)
# --- END inline:scripts/classify-touched-paths.sh ---

# --- BEGIN inline:scripts/pr-body-to-deps.sh ---
pr_body_to_deps() (
# pr-body-to-deps.sh — extract Dependabot dep bumps from a PR body, emit TSV.
Expand Down Expand Up @@ -627,18 +681,19 @@ jobs:
# --- Extract dependencies via standalone script (Task 8) ---
DEPS_TSV=$(echo "$DIFF" | extract_deps)

# --- Compute touched paths and unsupported subset ONCE, up front. ---
# Both Layer 2 (PR-body fallback) and Layer 3 (fail-loud guard) consume
# these. Hoisted from inside Layer 2 to keep the two layers' inputs
# identical and to make the guard logic independent of DEPS_TSV being
# empty (fix for issue #62 — partial extraction silent-green).
TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)
UNSUPPORTED_PATHS=$(printf '%s\n' "$TOUCHED_PATHS" | classify_touched_paths 2>/dev/null || true)

# --- Layer 2: PR-body fallback (defense in depth for issue #52) ---
TOUCHED_PATHS=""
# Routing list unchanged — pyproject.toml and Pipfile remain pypi-routable
# here even though Layer 3 will still fire on them, since the guard
# reflects extract-deps parser support, not PR-body recovery support.
if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then
TOUCHED_PATHS=$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)
# Keep the pypi regex below in sync with filename_to_ecosystem() in
# scripts/extract-deps.sh — ONLY lockfiles the parser actually handles
# should route to the pypi fallback. Cargo.lock, yarn.lock, etc. are
# emitted by diff_touches_lockfile (for guard detection) but must NOT
# route here — they fall through and trip the fail-loud guard instead.
# Mixed-touch policy: if a PR touches BOTH a pypi lockfile AND an actions
# workflow YAML, pypi wins. The fallback parser handles one ecosystem per
# PR; actions bumps (if any) fall through to the guard and will refuse green.
ECO_HINT=""
if echo "$TOUCHED_PATHS" | grep -qE '((^|/)(uv|poetry)\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|Pipfile$)'; then
ECO_HINT="pypi"
Expand All @@ -652,17 +707,21 @@ jobs:
fi
fi

# --- Layer 3: Fail-loud guard (fix for issue #52) ---
# --- Layer 3: Fail-loud guard (fixes for issues #52 and #62) ---
# Two composed conditions:
# (a) Unsupported paths touched: scan cannot be proven complete (issue #62).
# (b) All touched paths supported but extraction yielded zero rows: parser
# miss on a supported file (issue #52).
# Unsupported takes message precedence — it's the more specific diagnosis.
GUARD_TRIGGERED=false
if [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ]; then
# Defense-in-depth: Layer 2 always sets TOUCHED_PATHS when it runs, but the
# ${:-} default guards against future refactors that might skip Layer 2.
TOUCHED_PATHS="${TOUCHED_PATHS:-$(echo "$DIFF" | diff_touches_lockfile 2>/dev/null || true)}"
if [ -n "$TOUCHED_PATHS" ]; then
GUARD_TRIGGERED=true
TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge."
fi
if [ -n "$UNSUPPORTED_PATHS" ]; then
GUARD_TRIGGERED=true
UNSUPPORTED_LIST=$(echo "$UNSUPPORTED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Diff touches dependency files the parser does not support (${UNSUPPORTED_LIST}). Manual review required; the cooldown/safety gate cannot scan these."
elif [ -z "$(echo "$DEPS_TSV" | sed '/^$/d')" ] && [ -n "$TOUCHED_PATHS" ]; then
GUARD_TRIGGERED=true
TOUCHED_LIST=$(echo "$TOUCHED_PATHS" | paste -sd, -)
EXTRACTION_WARNING="⚠️ Parser could not extract dependencies from this diff (touched: ${TOUCHED_LIST}). Manual review required before merge."
fi

# Populate ACTIONS, PY_DEPS, ACTION_VERSIONS, PY_VERSIONS from DEPS_TSV
Expand Down Expand Up @@ -1110,8 +1169,12 @@ jobs:
# shows both signals: header per priority, table reflecting actual scan data.

# ---- Results table (from scan state only) ----
if [ "$GUARD_TRIGGERED" = "true" ]; then
# Extraction failed — no scan ran.
if [ "$GUARD_TRIGGERED" = "true" ] && [ "$TOTAL" -eq 0 ]; then
# Extraction failed AND no advisories from any subset that did get scanned:
# show "(not scanned)". With the issue #62 fix the guard can ALSO fire on a
# partial-extraction PR where DEPS_TSV is non-empty and scans found
# advisories — that case falls through to the TOTAL>0 branch so the actual
# advisory IDs are preserved.
TABLE_HDR="| Source | Vulnerabilities |"
TABLE_SEP="|--------|----------------|"
ROW1="| GitHub Advisory (GHSA) | (not scanned) |"
Expand Down Expand Up @@ -1143,6 +1206,11 @@ jobs:
if [ "$GUARD_TRIGGERED" = "true" ]; then
RESULTS_HEADER="Dependency extraction failed — manual review required."
RESULTS_FOOTER="> Parser could not extract dependencies. Manual review required."
if [ "$TOTAL" -gt 0 ]; then
# Partial extraction: scans of the supported subset DID surface advisories.
# Cross-reference so the IDs aren't lost under the guard header.
RESULTS_FOOTER="${RESULTS_FOOTER}"$'\n'"> Also: ${TOTAL} advisory/ies were found in the scanned subset — see table above."
fi
elif [ "$HAS_SAFETY_ERROR" = "true" ]; then
RESULTS_HEADER="Scan completed with errors — see workflow logs."
RESULTS_FOOTER="> Scan errors prevented a clean verdict. Re-run or push to retry."
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-inline-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ INLINE_PAIRS=(
".github/workflows/dependency-cooldown.yml:scripts/check-release-age.sh"
".github/workflows/dependency-cooldown.yml:scripts/diff-touches-lockfile.sh"
".github/workflows/dependency-cooldown.yml:scripts/pr-body-to-deps.sh"
".github/workflows/dependency-cooldown.yml:scripts/classify-touched-paths.sh"
".github/workflows/dependency-safety.yml:scripts/extract-deps.sh"
".github/workflows/dependency-safety.yml:scripts/check-release-age.sh"
".github/workflows/dependency-safety.yml:scripts/diff-touches-lockfile.sh"
".github/workflows/dependency-safety.yml:scripts/pr-body-to-deps.sh"
".github/workflows/dependency-safety.yml:scripts/safety-verdict.sh"
".github/workflows/dependency-safety.yml:scripts/classify-touched-paths.sh"
".github/workflows/tag-release.yml:scripts/bump-version-files.sh"
)

Expand Down
Loading
Loading