From 1160bcc8dc89874b302b31245f80f02b63ee6beb Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 9 Nov 2026 09:16:44 +0100 Subject: [PATCH] Regenerate Dependabot pip locks with the repository lock script Dependabot's pip updater rewrites requirements*.txt without scripts/lock_requirements.ps1. It drops the --require-hashes header and the universal markers, so every Python update PR needed a manual rerun of the lock script. The Dependabot workflow gains a refresh-python-locks job next to the npm vendor refresh. It reuses the same gate in scripts/dependabot_vendor.py, now with a per-ecosystem profile: Dependabot actor and PR author, a dependabot/pip/ branch in this repository, a failed "Check dependency locks" step, and a change set limited to the three requirement inputs and locks. The lock inputs must be plain version specifiers, the lock script, gate and uv.toml must match the default branch, uv comes from uv.toml without a cache, and UV_NO_BUILD keeps resolution free of package code. Only the three lock files are staged and pushed. Write permissions move from the workflow to each job. The lock script now seeds uv with the committed versions only. uv kept seeded hash lines without checking them, so Dependabot's duplicated numpy hashes survived regeneration and -Check accepted an added hash. --- .github/workflows/dependabot-vendor.yml | 139 +++++++++++- docs/wiki/Development-and-Testing.md | 4 +- scripts/dependabot_vendor.py | 152 +++++++++---- scripts/lock_requirements.ps1 | 8 +- tests/test_dependabot_vendor.py | 284 +++++++++++++++++++++++- tests/test_dependency_policy.py | 4 +- tests/test_supply_chain_policy.py | 69 ++++++ tests/test_toolchain_maintenance.py | 48 +++- 8 files changed, 661 insertions(+), 47 deletions(-) diff --git a/.github/workflows/dependabot-vendor.yml b/.github/workflows/dependabot-vendor.yml index cc50e461..4e0f4e9e 100644 --- a/.github/workflows/dependabot-vendor.yml +++ b/.github/workflows/dependabot-vendor.yml @@ -5,10 +5,10 @@ on: workflows: [Tests] types: [completed] -permissions: - contents: write - pull-requests: read - actions: write +# Each job declares only what it needs. Both run the trusted default-branch +# workflow and scripts; Dependabot content is checked out only after the gate +# validates it and is never executed beyond the pinned generators. +permissions: {} jobs: refresh-vendor: @@ -19,6 +19,10 @@ jobs: github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'dependabot/npm_and_yarn/') runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: read + actions: write steps: - name: Checkout trusted default branch @@ -49,6 +53,7 @@ jobs: gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?filter=latest&per_page=100" \ > "$RUNNER_TEMP/jobs.json" python scripts/dependabot_vendor.py validate \ + --ecosystem npm \ --event "$GITHUB_EVENT_PATH" \ --pull-request "$RUNNER_TEMP/pull-request.json" \ --files "$RUNNER_TEMP/files.json" \ @@ -92,6 +97,7 @@ jobs: test "$(git rev-parse HEAD)" = "$HEAD_SHA" rsync --archive --delete "$RUNNER_TEMP/vendor/" static/vendor/ python "$RUNNER_TEMP/dependabot_vendor.py" stage \ + --ecosystem npm \ --root . \ --github-output "$GITHUB_OUTPUT" @@ -117,3 +123,128 @@ jobs: HEAD_REF: ${{ steps.context.outputs.head_ref }} EXPECTED_SHA: ${{ steps.commit.outputs.generated_sha }} run: gh workflow run tests.yml --ref "$HEAD_REF" -f expected_sha="$EXPECTED_SHA" + + # Dependabot's pip updater rewrites the lock files without the repository + # lock script, dropping the --require-hashes header and universal markers. + refresh-python-locks: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.actor.login == 'dependabot[bot]' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'dependabot/pip/') + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: read + actions: write + + steps: + - name: Checkout trusted default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.11' + + # The resolver version comes from the trusted checkout. No cache is + # restored or saved, so this job cannot seed caches for main. + - name: Set up uv + uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + with: + version-file: uv.toml + enable-cache: false + + - name: Fetch and validate Dependabot context + id: context + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] + [[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]] + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + > "$RUNNER_TEMP/pull-request.json" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + > "$RUNNER_TEMP/files.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?filter=latest&per_page=100" \ + > "$RUNNER_TEMP/jobs.json" + python scripts/dependabot_vendor.py validate \ + --ecosystem pip \ + --event "$GITHUB_EVENT_PATH" \ + --pull-request "$RUNNER_TEMP/pull-request.json" \ + --files "$RUNNER_TEMP/files.json" \ + --jobs "$RUNNER_TEMP/jobs.json" \ + --github-output "$GITHUB_OUTPUT" + + - name: Checkout validated Dependabot head + shell: bash + env: + HEAD_REF: ${{ steps.context.outputs.head_ref }} + HEAD_SHA: ${{ steps.context.outputs.head_sha }} + run: | + set -euo pipefail + sha256sum scripts/lock_requirements.ps1 scripts/dependabot_vendor.py uv.toml \ + > "$RUNNER_TEMP/trusted-script-checksums" + git fetch --no-tags origin "refs/heads/${HEAD_REF}" + test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" + git checkout --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$HEAD_SHA" + sha256sum --check "$RUNNER_TEMP/trusted-script-checksums" + cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py" + + # UV_NO_BUILD keeps resolution metadata-only: no source distribution + # build backend, and therefore no package code, runs in this job. + - name: Regenerate Python locks with the repository lock script + shell: bash + env: + UV_NO_BUILD: '1' + run: | + set -euo pipefail + python "$RUNNER_TEMP/dependabot_vendor.py" check-python-inputs --root . + pwsh -NoProfile -NonInteractive -File scripts/lock_requirements.ps1 + pwsh -NoProfile -NonInteractive -File scripts/lock_requirements.ps1 -Check + + - name: Commit regenerated locks to the validated branch + id: commit + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ steps.context.outputs.head_ref }} + HEAD_SHA: ${{ steps.context.outputs.head_sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$HEAD_SHA" + python "$RUNNER_TEMP/dependabot_vendor.py" stage \ + --ecosystem pip \ + --root . \ + --github-output "$GITHUB_OUTPUT" + + if [ "$(tail -n 1 "$GITHUB_OUTPUT")" = "changed=false" ]; then + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Regenerate Python locks with the repository lock script [dependabot skip]" + generated_sha="$(git rev-parse HEAD)" + + test "$(git ls-remote origin "refs/heads/${HEAD_REF}" | cut -f1)" = "$HEAD_SHA" + gh auth setup-git + git push origin "HEAD:refs/heads/${HEAD_REF}" + echo "generated_sha=${generated_sha}" >> "$GITHUB_OUTPUT" + + - name: Run tests for the regenerated locks + if: steps.commit.outputs.changed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ steps.context.outputs.head_ref }} + EXPECTED_SHA: ${{ steps.commit.outputs.generated_sha }} + run: gh workflow run tests.yml --ref "$HEAD_REF" -f expected_sha="$EXPECTED_SHA" diff --git a/docs/wiki/Development-and-Testing.md b/docs/wiki/Development-and-Testing.md index 14787cc7..5f63c0dc 100644 --- a/docs/wiki/Development-and-Testing.md +++ b/docs/wiki/Development-and-Testing.md @@ -31,7 +31,9 @@ Verify locks without modifying them: .\scripts\lock_requirements.ps1 -Check ``` -Use `-Upgrade` for an intentional refresh of all allowed resolutions; the default preserves existing pins where possible. `-Check` and `-Upgrade` cannot be combined. Do not hand-edit generated lock files. A dependency change must update the input, all applicable lock outputs, and relevant compatibility tests. Preserve the Python 3.11 marker branches when newer Python versions select different packages. +Use `-Upgrade` for an intentional refresh of all allowed resolutions; the default preserves existing pins where possible. `-Check` and `-Upgrade` cannot be combined. Do not hand-edit generated lock files. A dependency change must update the input, all applicable lock outputs, and relevant compatibility tests. Preserve the Python 3.11 marker branches when newer Python versions select different packages. Existing lock files only seed version preferences; their hash lines are discarded and every hash is taken from the package index, so `-Check` also rejects added or duplicated hashes. + +Dependabot's pip updater rewrites the lock files without this script. When the `dependency-locks` check of a Dependabot pip pull request fails, the `Dependabot vendor refresh` workflow validates the pull request, reruns the lock script with the pinned uv and pushes only the three lock files as a follow-up commit. It accepts only Dependabot-authored `dependabot/pip/*` branches that change nothing but the requirement inputs and locks, and lock inputs limited to plain version specifiers. ## Python tests diff --git a/scripts/dependabot_vendor.py b/scripts/dependabot_vendor.py index 45d9df6d..0ce98261 100644 --- a/scripts/dependabot_vendor.py +++ b/scripts/dependabot_vendor.py @@ -1,4 +1,10 @@ -"""Validate the trust boundary for automated Dependabot vendor updates.""" +"""Validate the trust boundary for automated Dependabot follow-up commits. + +Two Dependabot ecosystems need generated files that Dependabot cannot produce +itself: npm updates need refreshed ``static/vendor`` assets, and pip updates +need lock files from ``scripts/lock_requirements.ps1``. Each ecosystem has a +fixed profile; nothing outside a profile's files is ever accepted or staged. +""" import argparse import json @@ -6,15 +12,71 @@ import re import subprocess import sys +from typing import Callable, NamedTuple DEPENDABOT = 'dependabot[bot]' -BRANCH = re.compile( - r'dependabot/npm_and_yarn/[A-Za-z0-9][A-Za-z0-9._/-]{0,199}' -) SHA = re.compile(r'[0-9a-f]{40}') -ALLOWED_STATUSES = {'added', 'modified', 'removed'} MANIFESTS = {'package.json', 'package-lock.json'} +PYTHON_INPUTS = ('requirements.in', 'requirements-test.in', 'requirements-graph.in') +PYTHON_LOCKS = ('requirements.txt', 'requirements-test.txt', 'requirements-graph.txt') +_SPECIFIER = r'(?:===|==|~=|!=|<=|>=|<|>)\s*[A-Za-z0-9.*+!]+' +# Plain names with version specifiers only: no URLs, extras, markers, options +# or index overrides that could redirect resolution away from the default index. +PYTHON_REQUIREMENT = re.compile( + r'[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?' + rf'(?:\s*{_SPECIFIER}(?:\s*,\s*{_SPECIFIER})*)?' +) +PYTHON_INCLUDE = re.compile(r'-r ([a-z.-]+)') + + +def _vendor_filename(filename): + if filename in MANIFESTS: + return True + return ( + filename.startswith('static/vendor/') + and '..' not in filename.split('/') + and not filename.endswith('/') + ) + + +def _vendor_staged(filename): + return filename.startswith('static/vendor/') and '..' not in filename.split('/') + + +class Profile(NamedTuple): + branch: re.Pattern + allowed_file: Callable[[str], bool] + allowed_statuses: frozenset + required_files: frozenset + failed_job: re.Pattern + failed_step: str + stage_command: tuple + staged_file: Callable[[str], bool] + + +PROFILES = { + 'npm': Profile( + branch=re.compile(r'dependabot/npm_and_yarn/[A-Za-z0-9][A-Za-z0-9._/-]{0,199}'), + allowed_file=_vendor_filename, + allowed_statuses=frozenset({'added', 'modified', 'removed'}), + required_files=frozenset({'package-lock.json'}), + failed_job=re.compile(r'browser-e2e(?: \([1-9][0-9]*/[1-9][0-9]*\))?'), + failed_step='Check vendored frontend assets', + stage_command=('git', 'add', '--all', '--', 'static/vendor'), + staged_file=_vendor_staged, + ), + 'pip': Profile( + branch=re.compile(r'dependabot/pip/[A-Za-z0-9][A-Za-z0-9._/-]{0,199}'), + allowed_file=frozenset(PYTHON_INPUTS + PYTHON_LOCKS).__contains__, + allowed_statuses=frozenset({'modified'}), + required_files=frozenset(PYTHON_LOCKS), + failed_job=re.compile(r'dependency-locks'), + failed_step='Check dependency locks', + stage_command=('git', 'add', '--', *PYTHON_LOCKS), + staged_file=frozenset(PYTHON_LOCKS).__contains__, + ), +} def _load(path, expected_type): @@ -32,17 +94,8 @@ def _require(condition, message): raise ValueError(message) -def _allowed_filename(filename): - if filename in MANIFESTS: - return True - return ( - filename.startswith('static/vendor/') - and '..' not in filename.split('/') - and not filename.endswith('/') - ) - - -def validate(event, pull_request, files, jobs): +def validate(event, pull_request, files, jobs, ecosystem='npm'): + profile = PROFILES[ecosystem] run = event.get('workflow_run', {}) repository = event.get('repository', {}) repo_name = repository.get('full_name') @@ -62,9 +115,9 @@ def validate(event, pull_request, files, jobs): ) _require( isinstance(head_ref, str) - and BRANCH.fullmatch(head_ref) + and profile.branch.fullmatch(head_ref) and '..' not in head_ref, - 'workflow head branch is not an npm Dependabot branch', + f'workflow head branch is not a Dependabot {ecosystem} branch', ) _require(isinstance(head_sha, str) and SHA.fullmatch(head_sha), 'invalid head SHA') @@ -112,27 +165,27 @@ def validate(event, pull_request, files, jobs): _require(isinstance(item, dict), 'changed-file entry has an invalid shape') filename = item.get('filename') _require( - isinstance(filename, str) and _allowed_filename(filename), + isinstance(filename, str) and profile.allowed_file(filename), f'disallowed changed file: {filename}', ) _require( - item.get('status') in ALLOWED_STATUSES, + item.get('status') in profile.allowed_statuses, f'disallowed change status for {filename}', ) filenames.append(filename) _require(len(filenames) == len(set(filenames)), 'duplicate changed-file entries') - _require('package-lock.json' in filenames, 'package-lock.json was not changed') + _require( + profile.required_files.intersection(filenames), + f'none of {", ".join(sorted(profile.required_files))} was changed', + ) job_items = jobs.get('jobs') _require(isinstance(job_items, list), 'workflow jobs have an invalid shape') - failed_vendor_check = any( - re.fullmatch( - r'browser-e2e(?: \([1-9][0-9]*/[1-9][0-9]*\))?', - str(job.get('name', '')), - ) + failed_check = any( + profile.failed_job.fullmatch(str(job.get('name', ''))) and job.get('conclusion') == 'failure' and any( - step.get('name') == 'Check vendored frontend assets' + step.get('name') == profile.failed_step and step.get('conclusion') == 'failure' for step in job.get('steps', []) if isinstance(step, dict) @@ -140,16 +193,35 @@ def validate(event, pull_request, files, jobs): for job in job_items if isinstance(job, dict) ) - _require(failed_vendor_check, 'vendor check did not fail') + _require(failed_check, f'{profile.failed_step!r} did not fail') return head_ref, head_sha, pr_number -def stage_vendor(root): +def check_python_inputs(root): + """Reject lock inputs that could change where or what uv resolves.""" + root = Path(root) + for name in PYTHON_INPUTS: + path = root / name + _require(path.is_file() and not path.is_symlink(), f'{name} is not a regular file') + for number, raw in enumerate(path.read_text(encoding='utf-8').splitlines(), 1): + line = raw.strip() + if not line or line.startswith('#'): + continue + include = PYTHON_INCLUDE.fullmatch(line) + _require( + PYTHON_REQUIREMENT.fullmatch(line) + or (include and include.group(1) in PYTHON_INPUTS and include.group(1) != name), + f'{name}:{number} is not a plain pinned requirement', + ) + + +def stage(root, ecosystem='npm'): + profile = PROFILES[ecosystem] root = Path(root).resolve() _require((root / '.git').exists(), 'stage root is not a Git repository') subprocess.run( - ['git', 'add', '--all', '--', 'static/vendor'], + list(profile.stage_command), cwd=root, check=True, capture_output=True, @@ -164,12 +236,8 @@ def stage_vendor(root): ) staged = [name for name in result.stdout.split('\0') if name] _require( - all( - name.startswith('static/vendor/') - and '..' not in name.split('/') - for name in staged - ), - 'staged changes escaped static/vendor', + all(profile.staged_file(name) for name in staged), + 'staged changes escaped the generated files', ) return bool(staged) @@ -178,17 +246,24 @@ def main(argv=None): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='command', required=True) validate_parser = subparsers.add_parser('validate') + validate_parser.add_argument('--ecosystem', choices=sorted(PROFILES), default='npm') validate_parser.add_argument('--event', required=True) validate_parser.add_argument('--pull-request', required=True) validate_parser.add_argument('--files', required=True) validate_parser.add_argument('--jobs', required=True) validate_parser.add_argument('--github-output', required=True) + inputs_parser = subparsers.add_parser('check-python-inputs') + inputs_parser.add_argument('--root', required=True) stage_parser = subparsers.add_parser('stage') + stage_parser.add_argument('--ecosystem', choices=sorted(PROFILES), default='npm') stage_parser.add_argument('--root', required=True) stage_parser.add_argument('--github-output', required=True) args = parser.parse_args(argv) try: + if args.command == 'check-python-inputs': + check_python_inputs(args.root) + return 0 if args.command == 'validate': event = _load(args.event, dict) pull_request = _load(args.pull_request, dict) @@ -199,6 +274,7 @@ def main(argv=None): pull_request, files, jobs, + args.ecosystem, ) lines = [ f'head_ref={head_ref}', @@ -206,10 +282,10 @@ def main(argv=None): f'pr_number={pr_number}', ] else: - lines = [f'changed={str(stage_vendor(args.root)).lower()}'] + lines = [f'changed={str(stage(args.root, args.ecosystem)).lower()}'] with Path(args.github_output).open('a', encoding='utf-8', newline='\n') as output: output.write('\n'.join(lines) + '\n') - except (ValueError, subprocess.CalledProcessError) as exc: + except (OSError, UnicodeError, ValueError, subprocess.CalledProcessError) as exc: print(f'ERROR: {exc}', file=sys.stderr) return 1 return 0 diff --git a/scripts/lock_requirements.ps1 b/scripts/lock_requirements.ps1 index 6bd79049..eccd6c25 100644 --- a/scripts/lock_requirements.ps1 +++ b/scripts/lock_requirements.ps1 @@ -24,7 +24,13 @@ function New-LockFile { $temporaryLock = Join-Path $temporaryDirectory $LockName $committedLock = Join-Path $projectRoot $LockName if (Test-Path -LiteralPath $committedLock) { - Copy-Item -LiteralPath $committedLock -Destination $temporaryLock + # The committed lock seeds version preferences only. uv would carry its + # hash lines over unverified, so an added or duplicated hash would + # survive regeneration and pass -Check; hashes always come from the index. + $seedLines = [System.IO.File]::ReadAllLines($committedLock) | + Where-Object { $_ -notmatch '^\s*--hash=' } | + ForEach-Object { $_ -replace '\s*\\$', '' } + [System.IO.File]::WriteAllLines($temporaryLock, [string[]]$seedLines, [System.Text.UTF8Encoding]::new($false)) } $compileArguments = @( "pip", "compile", $InputFile, diff --git a/tests/test_dependabot_vendor.py b/tests/test_dependabot_vendor.py index 0fb3568e..52b7b83c 100644 --- a/tests/test_dependabot_vendor.py +++ b/tests/test_dependabot_vendor.py @@ -65,7 +65,7 @@ def _payloads(): return event, pull_request, files, jobs -def _run_validator(tmp_path, mutate=None): +def _run_validator(tmp_path, mutate=None, ecosystem=None): event, pull_request, files, jobs = _payloads() if mutate is not None: mutate(event, pull_request, files, jobs) @@ -87,6 +87,7 @@ def _run_validator(tmp_path, mutate=None): sys.executable, str(SCRIPT), 'validate', + *(['--ecosystem', ecosystem] if ecosystem else []), '--event', str(inputs['event']), '--pull-request', @@ -266,3 +267,284 @@ def test_stage_captures_added_modified_and_removed_vendor_files_only(tmp_path): 'static/vendor/removed.js', ] assert 'outside.txt' not in staged + + +PIP_BRANCH = 'dependabot/pip/python-minor-and-patch-34c4db8fb1' +PYTHON_INPUTS = ('requirements.in', 'requirements-test.in', 'requirements-graph.in') +PYTHON_LOCKS = ('requirements.txt', 'requirements-test.txt', 'requirements-graph.txt') + + +def _pip_mutation(event, pull_request, files, jobs): + event['workflow_run']['head_branch'] = PIP_BRANCH + pull_request['head']['ref'] = PIP_BRANCH + files[:] = [ + {'filename': 'requirements.in', 'status': 'modified'}, + {'filename': 'requirements.txt', 'status': 'modified'}, + {'filename': 'requirements-test.txt', 'status': 'modified'}, + ] + pull_request['changed_files'] = len(files) + jobs['jobs'] = [ + { + 'name': 'dependency-locks', + 'conclusion': 'failure', + 'steps': [ + {'name': 'Check dependency locks', 'conclusion': 'failure'}, + ], + } + ] + + +def _run_pip_validator(tmp_path, mutate=None): + def combined(event, pull_request, files, jobs): + _pip_mutation(event, pull_request, files, jobs) + if mutate is not None: + mutate(event, pull_request, files, jobs) + + return _run_validator(tmp_path, combined, ecosystem='pip') + + +def _set_branch(name): + def mutate(event, pull_request, _files, _jobs): + event['workflow_run']['head_branch'] = name + pull_request['head']['ref'] = name + + return mutate + + +def _replace_files(*entries): + def mutate(_event, pull_request, files, _jobs): + files[:] = [{'filename': name, 'status': status} for name, status in entries] + pull_request['changed_files'] = len(files) + + return mutate + + +def test_valid_dependabot_pip_run_emits_sanitized_push_coordinates(tmp_path): + result, output = _run_pip_validator(tmp_path) + + assert result.returncode == 0, result.stderr + assert output.read_text(encoding='utf-8').splitlines() == [ + f'head_ref={PIP_BRANCH}', + f'head_sha={HEAD_SHA}', + 'pr_number=81', + ] + + +@pytest.mark.parametrize( + ('mutate', 'message'), + [ + ( + lambda event, _pr, _files, _jobs: event['workflow_run']['actor'].update( + login='octocat' + ), + 'actor', + ), + ( + lambda _event, pr, _files, _jobs: pr['user'].update(login='octocat'), + 'author', + ), + (_set_branch('dependabot/npm_and_yarn/npm-minor-and-patch-1'), 'branch'), + (_set_branch('dependabot/pip/../main'), 'branch'), + (_set_branch('feature/dependabot/pip/x'), 'branch'), + ( + lambda event, _pr, _files, _jobs: event['workflow_run'][ + 'head_repository' + ].update(full_name='attacker/webssh'), + 'head repository', + ), + ( + _replace_files(('requirements.txt', 'modified'), ('uv.toml', 'modified')), + 'uv.toml', + ), + ( + _replace_files( + ('requirements.txt', 'modified'), + ('scripts/lock_requirements.ps1', 'modified'), + ), + 'lock_requirements.ps1', + ), + ( + _replace_files( + ('requirements.txt', 'modified'), + ('scripts/dependabot_vendor.py', 'modified'), + ), + 'dependabot_vendor.py', + ), + ( + _replace_files( + ('requirements.txt', 'modified'), + ('.github/workflows/tests.yml', 'modified'), + ), + 'tests.yml', + ), + ( + _replace_files(('requirements.txt', 'modified'), ('package-lock.json', 'modified')), + 'package-lock.json', + ), + (_replace_files(('requirements-dev.txt', 'added')), 'requirements-dev.txt'), + (_replace_files(('docs/requirements.txt', 'modified')), 'docs/requirements.txt'), + (_replace_files(('requirements.txt', 'removed')), 'status'), + (_replace_files(('requirements.txt', 'renamed')), 'status'), + (_replace_files(('requirements.in', 'modified')), 'was changed'), + ( + lambda _event, _pr, _files, jobs: jobs['jobs'][0]['steps'][0].update( + conclusion='success' + ), + 'did not fail', + ), + ( + lambda _event, _pr, _files, jobs: jobs['jobs'][0].update(name='pytest'), + 'did not fail', + ), + ], +) +def test_pip_gate_rejects_untrusted_context_and_files(tmp_path, mutate, message): + result, output = _run_pip_validator(tmp_path, mutate) + + assert result.returncode == 1 + assert message in result.stderr + assert not output.exists() + + +def test_npm_gate_rejects_pip_branches(tmp_path): + result, output = _run_validator(tmp_path, _pip_mutation, ecosystem='npm') + + assert result.returncode == 1 + assert 'branch' in result.stderr + assert not output.exists() + + +def test_pip_gate_rejects_npm_manifests(tmp_path): + result, output = _run_pip_validator( + tmp_path, + _replace_files(('package.json', 'modified'), ('package-lock.json', 'modified')), + ) + + assert result.returncode == 1 + assert 'package.json' in result.stderr + assert not output.exists() + + +def _check_inputs(root): + return subprocess.run( + [sys.executable, str(SCRIPT), 'check-python-inputs', '--root', str(root)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_repository_python_inputs_pass_the_input_gate(): + result = _check_inputs(ROOT) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + 'line', + [ + '--index-url https://evil.invalid/simple', + '--extra-index-url https://evil.invalid/simple', + '-i https://evil.invalid/simple', + '--find-links https://evil.invalid/', + 'paramiko @ https://evil.invalid/paramiko-5.1.0.tar.gz', + 'git+https://evil.invalid/paramiko.git', + '-e .', + '-r ../outside.in', + '-r requirements-graph.in', + '-c /etc/constraints.txt', + 'paramiko[all]==5.1.0', + "paramiko==5.1.0 ; python_version < '3.12'", + 'paramiko==5.1.0 --hash=sha256:' + '0' * 64, + ], +) +def test_python_input_gate_rejects_resolution_overrides(tmp_path, line): + for name in PYTHON_INPUTS: + (tmp_path / name).write_text( + (ROOT / name).read_text(encoding='utf-8'), encoding='utf-8' + ) + target = tmp_path / 'requirements-graph.in' + target.write_text(target.read_text(encoding='utf-8') + line + '\n', encoding='utf-8') + + result = _check_inputs(tmp_path) + + assert result.returncode == 1 + assert 'requirements-graph.in' in result.stderr + + +def test_python_input_gate_rejects_missing_inputs(tmp_path): + (tmp_path / 'requirements.in').write_text('Flask==3.1.3\n', encoding='utf-8') + + result = _check_inputs(tmp_path) + + assert result.returncode == 1 + assert 'requirements-test.in' in result.stderr + + +def _git(repository, *args): + return subprocess.run( + ['git', *args], cwd=repository, check=True, capture_output=True, text=True + ).stdout + + +def _commit_fixture(repository, names, content): + repository.mkdir() + for name in names: + (repository / name).write_text(content, encoding='utf-8') + _git(repository, 'init') + _git(repository, 'add', '.') + _git( + repository, + '-c', 'user.name=Test', + '-c', 'user.email=test@example.invalid', + 'commit', '-m', 'fixture', + ) + + +def _stage_pip(repository, output): + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + 'stage', + '--ecosystem', + 'pip', + '--root', + str(repository), + '--github-output', + str(output), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_pip_stage_captures_only_regenerated_lock_files(tmp_path): + repository = tmp_path / 'repository' + tracked = (*PYTHON_INPUTS, *PYTHON_LOCKS, 'uv.toml') + _commit_fixture(repository, tracked, 'old\n') + for name in tracked: + (repository / name).write_text('new\n', encoding='utf-8') + (repository / 'requirements-extra.txt').write_text('never stage\n', encoding='utf-8') + output = tmp_path / 'stage-output.txt' + + result = _stage_pip(repository, output) + + assert result.returncode == 0, result.stderr + assert output.read_text(encoding='utf-8').splitlines() == ['changed=true'] + staged = _git(repository, 'diff', '--cached', '--name-only').splitlines() + assert staged == sorted(PYTHON_LOCKS) + + +def test_pip_stage_reports_unchanged_locks(tmp_path): + repository = tmp_path / 'repository' + _commit_fixture(repository, PYTHON_LOCKS, 'same\n') + output = tmp_path / 'stage-output.txt' + + result = _stage_pip(repository, output) + + assert result.returncode == 0, result.stderr + assert output.read_text(encoding='utf-8').splitlines() == ['changed=false'] diff --git a/tests/test_dependency_policy.py b/tests/test_dependency_policy.py index a541a5f1..2973c08c 100644 --- a/tests/test_dependency_policy.py +++ b/tests/test_dependency_policy.py @@ -185,7 +185,9 @@ def test_lock_generator_reuses_committed_pins_unless_upgrade_is_explicit(): script = Path("scripts/lock_requirements.ps1").read_text(encoding="utf-8") assert "[switch]$Upgrade" in script - assert "Copy-Item -LiteralPath $committedLock -Destination $temporaryLock" in script + assert "[System.IO.File]::ReadAllLines($committedLock)" in script + assert "[System.IO.File]::WriteAllLines($temporaryLock, [string[]]$seedLines" in script + assert "Where-Object { $_ -notmatch '^\\s*--hash=' }" in script assert 'if ($Upgrade)' in script assert '$compileArguments += "--upgrade"' in script diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index 4cc9dfb6..d5c21f5d 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -413,6 +413,75 @@ def test_dependabot_vendor_refresh_executes_only_trusted_automation_scripts(): assert snapshot < checkout < verification < generation +def _dependabot_job(name): + workflow = (WORKFLOWS / 'dependabot-vendor.yml').read_text(encoding='utf-8') + jobs = re.split(r'^ (?=[a-z-]+:\n)', workflow.split('\njobs:\n', 1)[1], flags=re.M) + return next(job for job in jobs if job.startswith(f'{name}:\n')) + + +def test_dependabot_workflow_grants_write_access_per_job_only(): + workflow = (WORKFLOWS / 'dependabot-vendor.yml').read_text(encoding='utf-8') + header = workflow.split('\njobs:\n', 1)[0] + + assert re.search(r'^permissions: \{\}$', header, re.MULTILINE) + for name, ecosystem, prefix in ( + ('refresh-vendor', 'npm', 'dependabot/npm_and_yarn/'), + ('refresh-python-locks', 'pip', 'dependabot/pip/'), + ): + job = _dependabot_job(name) + assert re.search( + r'permissions:\n\s+contents: write\n\s+pull-requests: read\n\s+actions: write\n', + job, + ), name + assert "github.event.workflow_run.actor.login == 'dependabot[bot]'" in job + assert "github.event.workflow_run.conclusion == 'failure'" in job + assert "github.event.workflow_run.event == 'pull_request'" in job + assert ( + 'github.event.workflow_run.head_repository.full_name == github.repository' + in job + ) + assert f"startsWith(github.event.workflow_run.head_branch, '{prefix}')" in job + assert f'--ecosystem {ecosystem}' in job + assert job.count('--ecosystem ') == 2, name + assert 'persist-credentials: false' in job + assert 'secrets.' not in job + + +def test_dependabot_python_lock_refresh_runs_only_trusted_generators(): + job = _dependabot_job('refresh-python-locks') + + setup_uv = job.index('astral-sh/setup-uv@') + validation = job.index('scripts/dependabot_vendor.py validate') + snapshot = job.index( + 'sha256sum scripts/lock_requirements.ps1 scripts/dependabot_vendor.py uv.toml' + ) + checkout = job.index('git checkout --detach FETCH_HEAD') + verification = job.index('sha256sum --check "$RUNNER_TEMP/trusted-script-checksums"') + copy = job.index('cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py"') + inputs = job.index('python "$RUNNER_TEMP/dependabot_vendor.py" check-python-inputs') + generation = job.index('-File scripts/lock_requirements.ps1\n') + check = job.index('-File scripts/lock_requirements.ps1 -Check') + stage = job.index('python "$RUNNER_TEMP/dependabot_vendor.py" stage') + remote_check = job.index('git ls-remote origin') + credentials = job.index('gh auth setup-git') + push = job.index('git push origin "HEAD:refs/heads/${HEAD_REF}"') + + assert setup_uv < validation < snapshot < checkout < verification < copy + assert copy < inputs < generation < check < stage < remote_check < credentials < push + assert 'version-file: uv.toml' in job + assert 'enable-cache: false' in job + assert "UV_NO_BUILD: '1'" in job + assert "python-version: '3.11'" in job + assert '-Upgrade' not in job + assert 'pip install' not in job + assert '[dependabot skip]' in job + assert 'gh workflow run tests.yml --ref "$HEAD_REF" -f expected_sha="$EXPECTED_SHA"' in job + + generate = job.split('- name: Regenerate Python locks', 1)[1].split('- name:', 1)[0] + assert 'GH_TOKEN' not in generate + assert 'github.token' not in generate + + def test_trivy_suppressions_are_justified_and_expire(): policy = json.loads( (ROOT / '.trivyignore.yaml').read_text(encoding='utf-8') diff --git a/tests/test_toolchain_maintenance.py b/tests/test_toolchain_maintenance.py index 385202b8..4b9058ca 100644 --- a/tests/test_toolchain_maintenance.py +++ b/tests/test_toolchain_maintenance.py @@ -38,7 +38,7 @@ def test_every_uv_setup_consumes_the_same_required_version(): assert 'version-file: uv.toml' in block, path assert not re.search(r'^\s+version:', block, re.M), path setups.append(path) - assert len(setups) == 6 + assert len(setups) == 7 def test_scanner_fallbacks_use_the_same_pinned_release_images(): @@ -113,3 +113,49 @@ def test_lock_generator_preserves_existing_files_without_a_successful_update(tmp assert '-Check and -Upgrade cannot be used together' in result.stderr assert 'resolver must not run' not in result.stderr assert {name: (tmp_path / (name + '.txt')).read_bytes() for name in names} == original + + +def test_lock_generator_never_seeds_the_resolver_with_committed_hashes(tmp_path): + """uv keeps seeded hash lines unverified, so -Check could not detect added hashes.""" + pwsh = shutil.which('pwsh') + if not pwsh: + pytest.skip('PowerShell is required to exercise the real lock generator') + scripts = tmp_path / 'scripts' + scripts.mkdir() + shutil.copyfile(ROOT / 'scripts/lock_requirements.ps1', scripts / 'lock_requirements.ps1') + names = ('requirements', 'requirements-test', 'requirements-graph') + bogus = '--hash=sha256:' + 'ab' * 32 + for name in names: + (tmp_path / (name + '.in')).write_text('example==1\n', encoding='utf-8') + (tmp_path / (name + '.txt')).write_text( + '--require-hashes\n' + "example==1 ; sys_platform == 'win32' \\\n" + f' {bogus} \\\n' + f' {bogus}\n' + ' # via -r requirements.in\n', + encoding='utf-8', + ) + seeds = tmp_path / 'seeds' + seeds.mkdir() + runner = tmp_path / 'run.ps1' + runner.write_text('''function global:uv { + $outputIndex = [array]::IndexOf($args, '--output-file') + 1 + $output = $args[$outputIndex] + Copy-Item -LiteralPath $output -Destination (Join-Path $env:LOCK_TEST_SEEDS (Split-Path $output -Leaf)) + [System.IO.File]::WriteAllText($output, "example==1`n") + $global:LASTEXITCODE = 0 +} +try { & ./scripts/lock_requirements.ps1 -Check; exit 0 } +catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 } +''', encoding='utf-8') + result = subprocess.run([pwsh, '-NoProfile', '-File', str(runner)], cwd=tmp_path, + env={**os.environ, 'LOCK_TEST_SEEDS': str(seeds)}, + capture_output=True, text=True, encoding='utf-8', timeout=30) + assert result.returncode == 1 + assert 'requirements.txt is out of date' in result.stderr + seed = (seeds / 'requirements.txt').read_text(encoding='utf-8') + assert seed.splitlines() == [ + '--require-hashes', + "example==1 ; sys_platform == 'win32'", + ' # via -r requirements.in', + ]