From 51b7d5fd6daf5fd5da84893ba58e3962a52893b8 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 4 Aug 2026 10:16:53 +0200 Subject: [PATCH 1/3] Automate Dependabot vendor refreshes --- .github/workflows/dependabot-vendor.yml | 109 ++++++++++++++++ .github/workflows/tests.yml | 1 + scripts/dependabot_vendor.py | 153 +++++++++++++++++++++++ tests/test_dependabot_vendor.py | 158 ++++++++++++++++++++++++ tests/test_supply_chain_policy.py | 21 ++++ 5 files changed, 442 insertions(+) create mode 100644 .github/workflows/dependabot-vendor.yml create mode 100644 scripts/dependabot_vendor.py create mode 100644 tests/test_dependabot_vendor.py diff --git a/.github/workflows/dependabot-vendor.yml b/.github/workflows/dependabot-vendor.yml new file mode 100644 index 0000000..c3ad67c --- /dev/null +++ b/.github/workflows/dependabot-vendor.yml @@ -0,0 +1,109 @@ +name: Dependabot vendor refresh + +on: + workflow_run: + workflows: [Tests] + types: [completed] + +permissions: + contents: write + pull-requests: read + actions: write + +jobs: + refresh-vendor: + if: >- + github.event.workflow_run.event == 'pull_request' && + 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/npm_and_yarn/') + runs-on: ubuntu-24.04 + + steps: + - name: Checkout trusted default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - 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: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[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" + python scripts/dependabot_vendor.py validate \ + --event "$GITHUB_EVENT_PATH" \ + --pull-request "$RUNNER_TEMP/pull-request.json" \ + --files "$RUNNER_TEMP/files.json" \ + --github-output "$GITHUB_OUTPUT" + + - name: Generate vendor assets from locked dependencies + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ steps.context.outputs.head_sha }} + run: | + set -euo pipefail + gh api -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/package.json?ref=${HEAD_SHA}" \ + > package.json + gh api -H "Accept: application/vnd.github.raw+json" \ + "repos/${GITHUB_REPOSITORY}/contents/package-lock.json?ref=${HEAD_SHA}" \ + > package-lock.json + npm ci --ignore-scripts + node scripts/vendor.js + npm run vendor:check + cp -a static/vendor "$RUNNER_TEMP/vendor" + + - name: Commit generated assets to the validated branch + id: commit + shell: bash + env: + HEAD_REF: ${{ steps.context.outputs.head_ref }} + HEAD_SHA: ${{ steps.context.outputs.head_sha }} + run: | + set -euo pipefail + git fetch --no-tags origin "refs/heads/${HEAD_REF}" + test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" + git checkout --detach FETCH_HEAD + rsync --archive --delete "$RUNNER_TEMP/vendor/" static/vendor/ + + if git diff --quiet -- static/vendor; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + while IFS= read -r changed_file; do + [[ "$changed_file" == static/vendor/* ]] + done < <(git diff --name-only) + + git add -- static/vendor + while IFS= read -r staged_file; do + [[ "$staged_file" == static/vendor/* ]] + done < <(git diff --cached --name-only) + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Update vendored frontend assets [dependabot skip]" + + test "$(git ls-remote origin "refs/heads/${HEAD_REF}" | cut -f1)" = "$HEAD_SHA" + git push origin "HEAD:refs/heads/${HEAD_REF}" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Run tests for the generated commit + if: steps.commit.outputs.changed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ steps.context.outputs.head_ref }} + run: gh workflow run tests.yml --ref "$HEAD_REF" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9dad875..df9d6c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: # Cancel superseded runs on the same ref to save CI minutes. concurrency: diff --git a/scripts/dependabot_vendor.py b/scripts/dependabot_vendor.py new file mode 100644 index 0000000..88242ca --- /dev/null +++ b/scripts/dependabot_vendor.py @@ -0,0 +1,153 @@ +"""Validate the trust boundary for automated Dependabot vendor updates.""" + +import argparse +import json +from pathlib import Path +import re +import sys + + +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'} + + +def _load(path, expected_type): + try: + value = json.loads(Path(path).read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f'cannot read {path}: {exc}') from exc + if not isinstance(value, expected_type): + raise ValueError(f'{path} has an unexpected JSON shape') + return value + + +def _require(condition, message): + if not condition: + 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): + run = event.get('workflow_run', {}) + repository = event.get('repository', {}) + repo_name = repository.get('full_name') + default_branch = repository.get('default_branch') + head_ref = run.get('head_branch') + head_sha = run.get('head_sha') + + _require(event.get('action') == 'completed', 'workflow run is not completed') + _require(run.get('name') == 'Tests', 'unexpected workflow name') + _require(run.get('event') == 'pull_request', 'workflow was not a pull request run') + _require(run.get('actor', {}).get('login') == DEPENDABOT, 'unexpected workflow actor') + _require(isinstance(repo_name, str) and repo_name, 'missing repository identity') + _require( + run.get('head_repository', {}).get('full_name') == repo_name, + 'workflow head repository does not match the base repository', + ) + _require( + isinstance(head_ref, str) + and BRANCH.fullmatch(head_ref) + and '..' not in head_ref, + 'workflow head branch is not an npm Dependabot branch', + ) + _require(isinstance(head_sha, str) and SHA.fullmatch(head_sha), 'invalid head SHA') + + run_prs = run.get('pull_requests') + _require(isinstance(run_prs, list) and len(run_prs) == 1, 'expected one pull request') + pr_number = pull_request.get('number') + _require( + isinstance(pr_number, int) + and pr_number > 0 + and run_prs[0].get('number') == pr_number, + 'pull request number does not match the workflow run', + ) + _require(pull_request.get('state') == 'open', 'pull request is not open') + _require( + pull_request.get('user', {}).get('login') == DEPENDABOT, + 'pull request author is not Dependabot', + ) + _require( + pull_request.get('head', {}).get('repo', {}).get('full_name') == repo_name, + 'pull request head repository does not match', + ) + _require( + pull_request.get('head', {}).get('ref') == head_ref, + 'pull request head branch does not match the workflow run', + ) + _require( + pull_request.get('head', {}).get('sha') == head_sha, + 'pull request head SHA does not match the workflow run', + ) + _require( + isinstance(default_branch, str) + and pull_request.get('base', {}).get('ref') == default_branch, + 'pull request does not target the default branch', + ) + + changed_files = pull_request.get('changed_files') + _require( + isinstance(changed_files, int) + and 0 < changed_files <= 20 + and changed_files == len(files), + 'changed-file count is incomplete or outside the safe limit', + ) + filenames = [] + for item in files: + _require(isinstance(item, dict), 'changed-file entry has an invalid shape') + filename = item.get('filename') + _require( + isinstance(filename, str) and _allowed_filename(filename), + f'disallowed changed file: {filename}', + ) + _require( + item.get('status') in 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') + + return head_ref, head_sha, pr_number + + +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('--event', required=True) + validate_parser.add_argument('--pull-request', required=True) + validate_parser.add_argument('--files', required=True) + validate_parser.add_argument('--github-output', required=True) + args = parser.parse_args(argv) + + try: + event = _load(args.event, dict) + pull_request = _load(args.pull_request, dict) + files = _load(args.files, list) + head_ref, head_sha, pr_number = validate(event, pull_request, files) + with Path(args.github_output).open('a', encoding='utf-8', newline='\n') as output: + output.write(f'head_ref={head_ref}\n') + output.write(f'head_sha={head_sha}\n') + output.write(f'pr_number={pr_number}\n') + except ValueError as exc: + print(f'ERROR: {exc}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_dependabot_vendor.py b/tests/test_dependabot_vendor.py new file mode 100644 index 0000000..d3a75db --- /dev/null +++ b/tests/test_dependabot_vendor.py @@ -0,0 +1,158 @@ +"""Behavior tests for the privileged Dependabot vendor gate.""" + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts' / 'dependabot_vendor.py' +HEAD_SHA = 'a' * 40 + + +def _payloads(): + event = { + 'action': 'completed', + 'repository': { + 'full_name': 'bifrost0x/webssh', + 'default_branch': 'main', + }, + 'workflow_run': { + 'name': 'Tests', + 'event': 'pull_request', + 'head_branch': 'dependabot/npm_and_yarn/npm-minor-and-patch-123', + 'head_sha': HEAD_SHA, + 'head_repository': {'full_name': 'bifrost0x/webssh'}, + 'actor': {'login': 'dependabot[bot]'}, + 'pull_requests': [{'number': 81}], + }, + } + pull_request = { + 'number': 81, + 'state': 'open', + 'user': {'login': 'dependabot[bot]'}, + 'head': { + 'ref': 'dependabot/npm_and_yarn/npm-minor-and-patch-123', + 'sha': HEAD_SHA, + 'repo': {'full_name': 'bifrost0x/webssh'}, + }, + 'base': {'ref': 'main'}, + 'changed_files': 2, + } + files = [ + {'filename': 'package.json', 'status': 'modified'}, + {'filename': 'package-lock.json', 'status': 'modified'}, + ] + return event, pull_request, files + + +def _run_validator(tmp_path, mutate=None): + event, pull_request, files = _payloads() + if mutate is not None: + mutate(event, pull_request, files) + + inputs = {} + for name, payload in ( + ('event', event), + ('pull-request', pull_request), + ('files', files), + ): + path = tmp_path / f'{name}.json' + path.write_text(json.dumps(payload), encoding='utf-8') + inputs[name] = path + + output = tmp_path / 'github-output.txt' + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + 'validate', + '--event', + str(inputs['event']), + '--pull-request', + str(inputs['pull-request']), + '--files', + str(inputs['files']), + '--github-output', + str(output), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + return result, output + + +def test_valid_dependabot_npm_run_emits_sanitized_push_coordinates(tmp_path): + result, output = _run_validator(tmp_path) + + assert result.returncode == 0, result.stderr + assert output.read_text(encoding='utf-8').splitlines() == [ + 'head_ref=dependabot/npm_and_yarn/npm-minor-and-patch-123', + f'head_sha={HEAD_SHA}', + 'pr_number=81', + ] + + +def test_gate_rejects_pr_code_outside_manifests_and_generated_vendor(tmp_path): + def add_untrusted_script(_event, pull_request, files): + files.append({'filename': 'scripts/vendor.js', 'status': 'modified'}) + pull_request['changed_files'] = len(files) + + result, output = _run_validator(tmp_path, add_untrusted_script) + + assert result.returncode == 1 + assert 'scripts/vendor.js' in result.stderr + assert not output.exists() + + +def test_gate_rejects_mismatched_workflow_and_live_pr_heads(tmp_path): + def change_pr_head(_event, pull_request, _files): + pull_request['head']['sha'] = 'b' * 40 + + result, output = _run_validator(tmp_path, change_pr_head) + + assert result.returncode == 1 + assert 'head SHA' in result.stderr + assert not output.exists() + + +@pytest.mark.parametrize( + ('mutate', 'message'), + [ + ( + lambda event, _pr, _files: event['workflow_run']['actor'].update( + login='octocat' + ), + 'actor', + ), + ( + lambda _event, pr, _files: pr['user'].update(login='octocat'), + 'author', + ), + ( + lambda event, _pr, _files: event['workflow_run'].update( + head_branch='feature/not-dependabot' + ), + 'branch', + ), + ( + lambda _event, pr, _files: pr['base'].update(ref='release'), + 'default branch', + ), + ], +) +def test_gate_rejects_non_dependabot_or_cross_context_runs( + tmp_path, + mutate, + message, +): + result, output = _run_validator(tmp_path, mutate) + + assert result.returncode == 1 + assert message in result.stderr.lower() + assert not output.exists() diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index 417e891..ac78f7e 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -264,6 +264,27 @@ def test_ci_rejects_stale_vendored_frontend_assets(): ) +def test_dependabot_vendor_refresh_uses_a_separate_validated_write_workflow(): + workflow = (WORKFLOWS / 'dependabot-vendor.yml').read_text( + encoding='utf-8' + ) + tests_workflow = (WORKFLOWS / 'tests.yml').read_text(encoding='utf-8') + + assert 'workflow_run:' in workflow + assert 'workflows: [Tests]' in workflow + assert 'contents: write' in workflow + assert 'pull-requests: read' in workflow + assert 'actions: write' in workflow + assert 'pull_request_target' not in workflow + assert 'scripts/dependabot_vendor.py validate' in workflow + assert 'npm ci --ignore-scripts' in workflow + assert 'node scripts/vendor.js' in workflow + assert '[dependabot skip]' in workflow + assert 'git diff --cached --name-only' in workflow + assert 'gh workflow run tests.yml' in workflow + assert 'workflow_dispatch:' in tests_workflow + + def test_readme_describes_current_transfer_and_log_rotation_contracts(): readme = (ROOT / 'README.md').read_text(encoding='utf-8') From ca3066f7d50a4afe8fed89b57e193d01ae163e75 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 4 Aug 2026 10:30:31 +0200 Subject: [PATCH 2/3] Harden Dependabot vendor workflow --- .github/workflows/dependabot-vendor.yml | 35 ++++--- .github/workflows/tests.yml | 27 ++++++ scripts/dependabot_vendor.py | 81 ++++++++++++++-- tests/test_dependabot_vendor.py | 118 ++++++++++++++++++++++-- tests/test_supply_chain_policy.py | 9 +- 5 files changed, 237 insertions(+), 33 deletions(-) diff --git a/.github/workflows/dependabot-vendor.yml b/.github/workflows/dependabot-vendor.yml index c3ad67c..349dd2b 100644 --- a/.github/workflows/dependabot-vendor.yml +++ b/.github/workflows/dependabot-vendor.yml @@ -14,6 +14,7 @@ jobs: refresh-vendor: 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/npm_and_yarn/') @@ -34,20 +35,25 @@ jobs: 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 \ --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: Generate vendor assets from locked dependencies + - name: Fetch locked manifests shell: bash env: GH_TOKEN: ${{ github.token }} @@ -60,9 +66,15 @@ jobs: gh api -H "Accept: application/vnd.github.raw+json" \ "repos/${GITHUB_REPOSITORY}/contents/package-lock.json?ref=${HEAD_SHA}" \ > package-lock.json + + - name: Generate vendor assets from locked dependencies + shell: bash + run: | + set -euo pipefail npm ci --ignore-scripts node scripts/vendor.js npm run vendor:check + cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py" cp -a static/vendor "$RUNNER_TEMP/vendor" - name: Commit generated assets to the validated branch @@ -77,28 +89,22 @@ jobs: test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" git checkout --detach FETCH_HEAD rsync --archive --delete "$RUNNER_TEMP/vendor/" static/vendor/ + python "$RUNNER_TEMP/dependabot_vendor.py" stage \ + --root . \ + --github-output "$GITHUB_OUTPUT" - if git diff --quiet -- static/vendor; then - echo "changed=false" >> "$GITHUB_OUTPUT" + if [ "$(tail -n 1 "$GITHUB_OUTPUT")" = "changed=false" ]; then exit 0 fi - while IFS= read -r changed_file; do - [[ "$changed_file" == static/vendor/* ]] - done < <(git diff --name-only) - - git add -- static/vendor - while IFS= read -r staged_file; do - [[ "$staged_file" == static/vendor/* ]] - done < <(git diff --cached --name-only) - git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "Update vendored frontend assets [dependabot skip]" + generated_sha="$(git rev-parse HEAD)" test "$(git ls-remote origin "refs/heads/${HEAD_REF}" | cut -f1)" = "$HEAD_SHA" git push origin "HEAD:refs/heads/${HEAD_REF}" - echo "changed=true" >> "$GITHUB_OUTPUT" + echo "generated_sha=${generated_sha}" >> "$GITHUB_OUTPUT" - name: Run tests for the generated commit if: steps.commit.outputs.changed == 'true' @@ -106,4 +112,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ steps.context.outputs.head_ref }} - run: gh workflow run tests.yml --ref "$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/.github/workflows/tests.yml b/.github/workflows/tests.yml index df9d6c1..632a9c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + expected_sha: + description: Commit SHA that this manually dispatched run must test + required: true + type: string # Cancel superseded runs on the same ref to save CI minutes. concurrency: @@ -13,7 +18,24 @@ concurrency: cancel-in-progress: true jobs: + dispatch-integrity: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Verify manually dispatched commit identity + shell: bash + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + run: | + set -eu + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$GITHUB_SHA" = "$EXPECTED_SHA" + fi + dependency-locks: + needs: dispatch-integrity runs-on: ubuntu-24.04 permissions: contents: read @@ -37,6 +59,7 @@ jobs: run: ./scripts/lock_requirements.ps1 -Check pytest: + needs: dispatch-integrity name: ${{ matrix.check_name }} runs-on: ubuntu-24.04 permissions: @@ -84,6 +107,7 @@ jobs: run: pytest tests/ -v redis-rate-limiter: + needs: dispatch-integrity name: Redis rate limiter (${{ matrix.redis-version }}) runs-on: ubuntu-24.04 permissions: @@ -130,6 +154,7 @@ jobs: run: pytest tests/test_rate_limiter.py -v ssh-integration: + needs: dispatch-integrity runs-on: ubuntu-24.04 permissions: contents: read @@ -156,6 +181,7 @@ jobs: run: python scripts/run_integration_tests.py browser-e2e: + needs: dispatch-integrity runs-on: ubuntu-24.04 permissions: contents: read @@ -209,6 +235,7 @@ jobs: retention-days: 7 container-threading-smoke: + needs: dispatch-integrity runs-on: ubuntu-24.04 permissions: contents: read diff --git a/scripts/dependabot_vendor.py b/scripts/dependabot_vendor.py index 88242ca..3664cc6 100644 --- a/scripts/dependabot_vendor.py +++ b/scripts/dependabot_vendor.py @@ -4,6 +4,7 @@ import json from pathlib import Path import re +import subprocess import sys @@ -41,7 +42,7 @@ def _allowed_filename(filename): ) -def validate(event, pull_request, files): +def validate(event, pull_request, files, jobs): run = event.get('workflow_run', {}) repository = event.get('repository', {}) repo_name = repository.get('full_name') @@ -52,6 +53,7 @@ def validate(event, pull_request, files): _require(event.get('action') == 'completed', 'workflow run is not completed') _require(run.get('name') == 'Tests', 'unexpected workflow name') _require(run.get('event') == 'pull_request', 'workflow was not a pull request run') + _require(run.get('conclusion') == 'failure', 'workflow conclusion was not failure') _require(run.get('actor', {}).get('login') == DEPENDABOT, 'unexpected workflow actor') _require(isinstance(repo_name, str) and repo_name, 'missing repository identity') _require( @@ -121,9 +123,54 @@ def validate(event, pull_request, files): _require(len(filenames) == len(set(filenames)), 'duplicate changed-file entries') _require('package-lock.json' in filenames, 'package-lock.json was not changed') + job_items = jobs.get('jobs') + _require(isinstance(job_items, list), 'workflow jobs have an invalid shape') + failed_vendor_check = any( + job.get('name') == 'browser-e2e' + and job.get('conclusion') == 'failure' + and any( + step.get('name') == 'Check vendored frontend assets' + and step.get('conclusion') == 'failure' + for step in job.get('steps', []) + if isinstance(step, dict) + ) + for job in job_items + if isinstance(job, dict) + ) + _require(failed_vendor_check, 'vendor check did not fail') + return head_ref, head_sha, pr_number +def stage_vendor(root): + root = Path(root).resolve() + _require((root / '.git').exists(), 'stage root is not a Git repository') + subprocess.run( + ['git', 'add', '--all', '--', 'static/vendor'], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + result = subprocess.run( + ['git', 'diff', '--cached', '--name-only', '-z'], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + 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', + ) + return bool(staged) + + def main(argv=None): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest='command', required=True) @@ -131,19 +178,35 @@ def main(argv=None): 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) + stage_parser = subparsers.add_parser('stage') + stage_parser.add_argument('--root', required=True) + stage_parser.add_argument('--github-output', required=True) args = parser.parse_args(argv) try: - event = _load(args.event, dict) - pull_request = _load(args.pull_request, dict) - files = _load(args.files, list) - head_ref, head_sha, pr_number = validate(event, pull_request, files) + if args.command == 'validate': + event = _load(args.event, dict) + pull_request = _load(args.pull_request, dict) + files = _load(args.files, list) + jobs = _load(args.jobs, dict) + head_ref, head_sha, pr_number = validate( + event, + pull_request, + files, + jobs, + ) + lines = [ + f'head_ref={head_ref}', + f'head_sha={head_sha}', + f'pr_number={pr_number}', + ] + else: + lines = [f'changed={str(stage_vendor(args.root)).lower()}'] with Path(args.github_output).open('a', encoding='utf-8', newline='\n') as output: - output.write(f'head_ref={head_ref}\n') - output.write(f'head_sha={head_sha}\n') - output.write(f'pr_number={pr_number}\n') - except ValueError as exc: + output.write('\n'.join(lines) + '\n') + except (ValueError, subprocess.CalledProcessError) as exc: print(f'ERROR: {exc}', file=sys.stderr) return 1 return 0 diff --git a/tests/test_dependabot_vendor.py b/tests/test_dependabot_vendor.py index d3a75db..6d7c632 100644 --- a/tests/test_dependabot_vendor.py +++ b/tests/test_dependabot_vendor.py @@ -21,8 +21,10 @@ def _payloads(): 'default_branch': 'main', }, 'workflow_run': { + 'id': 123456, 'name': 'Tests', 'event': 'pull_request', + 'conclusion': 'failure', 'head_branch': 'dependabot/npm_and_yarn/npm-minor-and-patch-123', 'head_sha': HEAD_SHA, 'head_repository': {'full_name': 'bifrost0x/webssh'}, @@ -46,19 +48,34 @@ def _payloads(): {'filename': 'package.json', 'status': 'modified'}, {'filename': 'package-lock.json', 'status': 'modified'}, ] - return event, pull_request, files + jobs = { + 'jobs': [ + { + 'name': 'browser-e2e', + 'conclusion': 'failure', + 'steps': [ + { + 'name': 'Check vendored frontend assets', + 'conclusion': 'failure', + } + ], + } + ] + } + return event, pull_request, files, jobs def _run_validator(tmp_path, mutate=None): - event, pull_request, files = _payloads() + event, pull_request, files, jobs = _payloads() if mutate is not None: - mutate(event, pull_request, files) + mutate(event, pull_request, files, jobs) inputs = {} for name, payload in ( ('event', event), ('pull-request', pull_request), ('files', files), + ('jobs', jobs), ): path = tmp_path / f'{name}.json' path.write_text(json.dumps(payload), encoding='utf-8') @@ -76,6 +93,8 @@ def _run_validator(tmp_path, mutate=None): str(inputs['pull-request']), '--files', str(inputs['files']), + '--jobs', + str(inputs['jobs']), '--github-output', str(output), ], @@ -99,7 +118,7 @@ def test_valid_dependabot_npm_run_emits_sanitized_push_coordinates(tmp_path): def test_gate_rejects_pr_code_outside_manifests_and_generated_vendor(tmp_path): - def add_untrusted_script(_event, pull_request, files): + def add_untrusted_script(_event, pull_request, files, _jobs): files.append({'filename': 'scripts/vendor.js', 'status': 'modified'}) pull_request['changed_files'] = len(files) @@ -111,7 +130,7 @@ def add_untrusted_script(_event, pull_request, files): def test_gate_rejects_mismatched_workflow_and_live_pr_heads(tmp_path): - def change_pr_head(_event, pull_request, _files): + def change_pr_head(_event, pull_request, _files, _jobs): pull_request['head']['sha'] = 'b' * 40 result, output = _run_validator(tmp_path, change_pr_head) @@ -125,23 +144,23 @@ def change_pr_head(_event, pull_request, _files): ('mutate', 'message'), [ ( - lambda event, _pr, _files: event['workflow_run']['actor'].update( + lambda event, _pr, _files, _jobs: event['workflow_run']['actor'].update( login='octocat' ), 'actor', ), ( - lambda _event, pr, _files: pr['user'].update(login='octocat'), + lambda _event, pr, _files, _jobs: pr['user'].update(login='octocat'), 'author', ), ( - lambda event, _pr, _files: event['workflow_run'].update( + lambda event, _pr, _files, _jobs: event['workflow_run'].update( head_branch='feature/not-dependabot' ), 'branch', ), ( - lambda _event, pr, _files: pr['base'].update(ref='release'), + lambda _event, pr, _files, _jobs: pr['base'].update(ref='release'), 'default branch', ), ], @@ -156,3 +175,84 @@ def test_gate_rejects_non_dependabot_or_cross_context_runs( assert result.returncode == 1 assert message in result.stderr.lower() assert not output.exists() + + +@pytest.mark.parametrize('conclusion', ['success', 'cancelled', 'skipped']) +def test_gate_rejects_runs_without_a_failed_vendor_check(tmp_path, conclusion): + def change_conclusion(event, _pull_request, _files, jobs): + event['workflow_run']['conclusion'] = conclusion + jobs['jobs'][0]['steps'][0]['conclusion'] = conclusion + + result, output = _run_validator(tmp_path, change_conclusion) + + assert result.returncode == 1 + assert 'vendor' in result.stderr.lower() or 'conclusion' in result.stderr.lower() + assert not output.exists() + + +def test_stage_captures_added_modified_and_removed_vendor_files_only(tmp_path): + repository = tmp_path / 'repository' + vendor = repository / 'static' / 'vendor' + vendor.mkdir(parents=True) + (vendor / 'modified.js').write_text('old', encoding='utf-8') + (vendor / 'removed.js').write_text('remove', encoding='utf-8') + subprocess.run(['git', 'init'], cwd=repository, check=True, capture_output=True) + subprocess.run( + ['git', 'add', 'static/vendor'], + cwd=repository, + check=True, + capture_output=True, + ) + subprocess.run( + [ + 'git', + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '-m', + 'fixture', + ], + cwd=repository, + check=True, + capture_output=True, + ) + + (vendor / 'modified.js').write_text('new', encoding='utf-8') + (vendor / 'removed.js').unlink() + (vendor / 'added.js').write_text('added', encoding='utf-8') + (repository / 'outside.txt').write_text('never stage', encoding='utf-8') + output = tmp_path / 'stage-output.txt' + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + 'stage', + '--root', + str(repository), + '--github-output', + str(output), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert output.read_text(encoding='utf-8').splitlines() == ['changed=true'] + staged = subprocess.run( + ['git', 'diff', '--cached', '--name-only'], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + assert staged == [ + 'static/vendor/added.js', + 'static/vendor/modified.js', + 'static/vendor/removed.js', + ] + assert 'outside.txt' not in staged diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index ac78f7e..a7e5b7c 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -277,11 +277,18 @@ def test_dependabot_vendor_refresh_uses_a_separate_validated_write_workflow(): assert 'actions: write' in workflow assert 'pull_request_target' not in workflow assert 'scripts/dependabot_vendor.py validate' in workflow + assert '--jobs' in workflow assert 'npm ci --ignore-scripts' in workflow assert 'node scripts/vendor.js' in workflow assert '[dependabot skip]' in workflow - assert 'git diff --cached --name-only' in workflow + assert ( + 'cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py"' + in workflow + ) + assert 'python "$RUNNER_TEMP/dependabot_vendor.py" stage' in workflow assert 'gh workflow run tests.yml' in workflow + assert '-f expected_sha="$EXPECTED_SHA"' in workflow + assert 'EXPECTED_SHA: ${{ inputs.expected_sha }}' in tests_workflow assert 'workflow_dispatch:' in tests_workflow From 50504a1ce5e4584ed5ea6c2793a5cf9b6feb6bdd Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Tue, 4 Aug 2026 10:34:23 +0200 Subject: [PATCH 3/3] Isolate vendor generation credentials --- .github/workflows/dependabot-vendor.yml | 6 +++++- tests/test_supply_chain_policy.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-vendor.yml b/.github/workflows/dependabot-vendor.yml index 349dd2b..4b9f165 100644 --- a/.github/workflows/dependabot-vendor.yml +++ b/.github/workflows/dependabot-vendor.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout trusted default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -73,7 +75,7 @@ jobs: set -euo pipefail npm ci --ignore-scripts node scripts/vendor.js - npm run vendor:check + node scripts/vendor.js --check cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py" cp -a static/vendor "$RUNNER_TEMP/vendor" @@ -81,6 +83,7 @@ jobs: id: commit shell: bash env: + GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ steps.context.outputs.head_ref }} HEAD_SHA: ${{ steps.context.outputs.head_sha }} run: | @@ -103,6 +106,7 @@ jobs: 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" diff --git a/tests/test_supply_chain_policy.py b/tests/test_supply_chain_policy.py index a7e5b7c..4369792 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -280,6 +280,10 @@ def test_dependabot_vendor_refresh_uses_a_separate_validated_write_workflow(): assert '--jobs' in workflow assert 'npm ci --ignore-scripts' in workflow assert 'node scripts/vendor.js' in workflow + assert 'node scripts/vendor.js --check' in workflow + assert 'npm run vendor:check' not in workflow + assert 'persist-credentials: false' in workflow + assert 'gh auth setup-git' in workflow assert '[dependabot skip]' in workflow assert ( 'cp scripts/dependabot_vendor.py "$RUNNER_TEMP/dependabot_vendor.py"'