Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
204 changes: 39 additions & 165 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,15 @@ jobs:
permissions:
contents: write
pull-requests: write
timeout-minutes: 10 # Prevent long-running jobs
timeout-minutes: 30 # Allow full-tree scan, scoped lock submissions, and base/head snapshot retry
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
# PR runs compare the exact base and head trees before asking the
# dependency graph to compare snapshots. This prevents an unrelated
# source/test-only PR from inheriting stale graph metadata as a fake
# dependency addition.
# PR runs inspect changes introduced after the shared ancestor.
# A dependency update merged into main after this PR branched must
# not turn a source-only PR into a dependency-review run.
fetch-depth: 0
persist-credentials: false

Expand All @@ -55,14 +54,19 @@ jobs:
run: |
python - <<'PY'
import os
import json
from pathlib import PurePosixPath
import subprocess

base = os.environ["BASE_SHA"]
head = os.environ["HEAD_SHA"]
merge_base = subprocess.run(
["git", "merge-base", base, head],
check=True,
capture_output=True,
text=True,
).stdout.strip()
output = subprocess.run(
["git", "diff", "--name-only", "-z", base, head],
["git", "diff", "--name-only", "-z", merge_base, head],
check=True,
capture_output=True,
).stdout
Expand All @@ -84,8 +88,8 @@ jobs:
}

def is_dependency_input(path: PurePosixPath) -> bool:
# Workflow code changes are covered by CI tests; they do not
# change a dependency graph and should not force a comparison.
# Workflow and Dependabot configuration changes are covered by
# CI tests; they do not change the installed dependency graph.
name = path.name
return (
name in exact_names
Expand All @@ -95,181 +99,51 @@ jobs:
and name.endswith((".in", ".txt"))
)
or name.endswith((".csproj", ".fsproj", ".gradle", ".gradle.kts"))
or path.as_posix() in {
".github/dependabot.yaml",
".github/dependabot.yml",
"vendor/modules.txt",
}
or path.as_posix() == "vendor/modules.txt"
)

changed = sorted(path.as_posix() for path in paths if is_dependency_input(path))
# A lockfile-only PR needs a snapshot of its changed ecosystem,
# not a repo-wide PipReport resolution of unrelated requirements.
# Keep the full scan for mixed/unsupported inputs and main pushes.
snapshot_detector = ""
if changed and all(path in {"pyproject.toml", "uv.lock"} for path in changed):
if "uv.lock" in changed:
snapshot_detector = "UvLock"
elif changed and all(PurePosixPath(path).name in {"package.json", "package-lock.json"} for path in changed):
locks = {path for path in changed if PurePosixPath(path).name == "package-lock.json"}
if locks:
workspace_patterns = []
root_lock_packages = {}
root_valid = True
if "package-lock.json" in locks:
def head_json(path):
content = subprocess.run(
["git", "show", f"{head}:{path}"],
check=True, capture_output=True,
).stdout
return json.loads(content)

try:
root = head_json("package.json")
workspaces = root.get("workspaces", [])
workspace_patterns = workspaces.get("packages", []) if isinstance(workspaces, dict) else workspaces
root_lock_packages = head_json("package-lock.json").get("packages", {})
root_valid = isinstance(workspace_patterns, list) and isinstance(root_lock_packages, dict)
except (subprocess.CalledProcessError, json.JSONDecodeError, AttributeError):
root_valid = False

def covered(manifest):
path = PurePosixPath(manifest)
if path.with_name("package-lock.json").as_posix() in locks:
return True
directory = path.parent.as_posix()
return (
"package-lock.json" in locks
and directory in root_lock_packages
and any(PurePosixPath(directory).match(pattern) for pattern in workspace_patterns)
)

if root_valid and all(covered(path) for path in changed if PurePosixPath(path).name == "package.json"):
snapshot_detector = "NpmLockfile3"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
handle.write(f"changed={'true' if changed else 'false'}\n")
handle.write(f"count={len(changed)}\n")
handle.write(f"snapshot_detector={snapshot_detector}\n")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
if changed:
handle.write("Dependency inputs changed:\n\n")
handle.writelines(f"- `{path}`\n" for path in changed)
else:
handle.write(
"No dependency inputs changed between the exact base "
"and head trees.\n"
"No dependency inputs introduced by this PR since "
"its merge base.\n"
)
PY

- name: Stage focused dependency inputs
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SNAPSHOT_DETECTOR: ${{ steps.dependency-changes.outputs.snapshot_detector }}
SNAPSHOT_BASE_SOURCE: ${{ runner.temp }}/dependency-review-base
SNAPSHOT_HEAD_SOURCE: ${{ runner.temp }}/dependency-review-head
run: |
python - <<'PY'
import os
import json
from pathlib import Path
import subprocess

detector = os.environ["SNAPSHOT_DETECTOR"]
if detector == "UvLock":
locks = [Path("uv.lock")]
elif detector == "NpmLockfile3":
delta = subprocess.run(
["git", "diff", "--name-only", "-z", os.environ["BASE_SHA"], os.environ["HEAD_SHA"]],
check=True, capture_output=True,
).stdout
locks = [Path(raw.decode("utf-8")) for raw in delta.split(b"\0") if raw
and Path(raw.decode("utf-8")).name == "package-lock.json"]
else:
raise ValueError(f"Unexpected focused snapshot detector: {detector}")

for sha, directory in (
(os.environ["BASE_SHA"], os.environ["SNAPSHOT_BASE_SOURCE"]),
(os.environ["HEAD_SHA"], os.environ["SNAPSHOT_HEAD_SOURCE"]),
):
target = Path(directory)
target.mkdir(parents=True, exist_ok=True)
for lock in locks:
manifest = lock.with_name("pyproject.toml" if lock.name == "uv.lock" else "package.json")
sources = {lock, manifest}
if lock == Path("package-lock.json"):
root_result = subprocess.run(
["git", "show", f"{sha}:package.json"],
capture_output=True,
)
lock_result = subprocess.run(
["git", "show", f"{sha}:package-lock.json"],
capture_output=True,
)
if root_result.returncode == 0 and lock_result.returncode == 0:
root = json.loads(root_result.stdout)
workspaces = root.get("workspaces", [])
patterns = workspaces.get("packages", []) if isinstance(workspaces, dict) else workspaces
lock_data = json.loads(lock_result.stdout)
for name in lock_data.get("packages", {}):
if name and any(Path(name).match(pattern) for pattern in patterns):
sources.add(Path(name) / "package.json")
for source in sources:
result = subprocess.run(
["git", "show", f"{sha}:{source.as_posix()}"], capture_output=True,
)
if result.returncode == 0:
destination = target / source
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(result.stdout)
PY

- name: Submit focused base dependency snapshot
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
with:
snapshot-sha: ${{ github.event.pull_request.base.sha }}
snapshot-ref: ${{ format('refs/heads/{0}', github.event.pull_request.base.ref) }}
filePath: ${{ runner.temp }}/dependency-review-base
correlator: focused-lockfile-review
detectorArgs: UvLock=EnableIfDefaultOff,NpmLockfile3=EnableIfDefaultOff

- name: Submit focused dependency snapshot
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
with:
snapshot-sha: ${{ github.event.pull_request.head.sha }}
snapshot-ref: ${{ format('refs/heads/{0}', github.event.pull_request.head.ref) }}
filePath: ${{ runner.temp }}/dependency-review-head
correlator: focused-lockfile-review
detectorArgs: UvLock=EnableIfDefaultOff,NpmLockfile3=EnableIfDefaultOff

- name: Submit dependency snapshots
if: >-
${{ github.event_name == 'push' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.changed == 'true' &&
steps.dependency-changes.outputs.snapshot_detector == '') }}
# Keep scanning the full tree, but bound each pip report. On a report
# timeout component-detection falls back to declared source dependencies.
steps.dependency-changes.outputs.changed == 'true') }}
# Scan the same full tree on main and on dependency PRs so GitHub compares
# matching snapshot sets. Bound each pip report; on a timeout, component-
# detection falls back to declared source dependencies. A 120-second
# bound produced base/head drift in the #2501 exact-lock canary.
env:
PipReportFileLevelTimeoutSeconds: "120"
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
PipReportFileLevelTimeoutSeconds: "240"
uses: advanced-security/component-detection-dependency-submission-action@a02aeda7a175ad4dd38a13c4d3ff2df9da8f2cf0 # v0.2.0
with:
snapshot-sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
snapshot-ref: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}

- name: Submit complete lock snapshot
if: >-
${{ github.event_name == 'push' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.changed == 'true') }}
env:
SNAPSHOT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
SNAPSHOT_REF: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}
GITHUB_TOKEN: ${{ github.token }}
run: python3 -m scripts.ci.submit_npm_lock_snapshot

- name: Dependency Review
id: review
if: >-
Expand All @@ -280,11 +154,11 @@ jobs:
base-ref: ${{ github.event.pull_request.base.sha }}
head-ref: ${{ github.event.pull_request.head.sha }}
fail-on-severity: moderate
# The dependency graph API can briefly report "no snapshots found for the
# head SHA" when a PR's dependency snapshot is submitted asynchronously and
# hasn't finished indexing yet. Retry instead of failing/warning on that race.
# Base and head snapshot submissions can overlap. In the exact-lock
# canary, the base snapshots became available after the former 300-second
# retry expired; wait for both sides before judging dependency changes.
retry-on-snapshot-warnings: true
retry-on-snapshot-warnings-timeout: 300
retry-on-snapshot-warnings-timeout: 600
# GHSA-w5hq-g745-h8pq: transitive uuid@8.3.2 pulled by next-auth@4.24 (which pins uuid ^8.3.2).
# The moderate advisory affects uuid v3/v5/v6 when a caller-provided buffer is passed; next-auth
# only calls v4(), so the vulnerable code path is unreachable here. The first patched uuid is
Expand Down Expand Up @@ -354,7 +228,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REVIEW_OUTCOME: ${{ steps.review.outcome }}
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/record_dependency_review.py
run: python3 -m scripts.ci.record_dependency_review

- name: Upload dependency review evidence
if: >-
Expand Down
Loading
Loading