diff --git a/.github/workflows/dependabot-vendor.yml b/.github/workflows/dependabot-vendor.yml new file mode 100644 index 0000000..4b9f165 --- /dev/null +++ b/.github/workflows/dependabot-vendor.yml @@ -0,0 +1,120 @@ +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.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/') + runs-on: ubuntu-24.04 + + 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 + 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_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: Fetch locked manifests + 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 + + - name: Generate vendor assets from locked dependencies + shell: bash + run: | + set -euo pipefail + npm ci --ignore-scripts + node scripts/vendor.js + node scripts/vendor.js --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 + 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 + 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/ + python "$RUNNER_TEMP/dependabot_vendor.py" stage \ + --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 "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" + gh auth setup-git + git push origin "HEAD:refs/heads/${HEAD_REF}" + echo "generated_sha=${generated_sha}" >> "$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 }} + 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 9dad875..632a9c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,12 @@ on: branches: [main] 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: @@ -12,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 @@ -36,6 +59,7 @@ jobs: run: ./scripts/lock_requirements.ps1 -Check pytest: + needs: dispatch-integrity name: ${{ matrix.check_name }} runs-on: ubuntu-24.04 permissions: @@ -83,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: @@ -129,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 @@ -155,6 +181,7 @@ jobs: run: python scripts/run_integration_tests.py browser-e2e: + needs: dispatch-integrity runs-on: ubuntu-24.04 permissions: contents: read @@ -208,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 new file mode 100644 index 0000000..3664cc6 --- /dev/null +++ b/scripts/dependabot_vendor.py @@ -0,0 +1,216 @@ +"""Validate the trust boundary for automated Dependabot vendor updates.""" + +import argparse +import json +from pathlib import Path +import re +import subprocess +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, jobs): + 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('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( + 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') + + 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) + 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('--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: + 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('\n'.join(lines) + '\n') + except (ValueError, subprocess.CalledProcessError) 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..6d7c632 --- /dev/null +++ b/tests/test_dependabot_vendor.py @@ -0,0 +1,258 @@ +"""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': { + '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'}, + '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'}, + ] + 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, jobs = _payloads() + if mutate is not None: + 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') + 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']), + '--jobs', + str(inputs['jobs']), + '--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, _jobs): + 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, _jobs): + 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, _jobs: event['workflow_run']['actor'].update( + login='octocat' + ), + 'actor', + ), + ( + lambda _event, pr, _files, _jobs: pr['user'].update(login='octocat'), + 'author', + ), + ( + lambda event, _pr, _files, _jobs: event['workflow_run'].update( + head_branch='feature/not-dependabot' + ), + 'branch', + ), + ( + lambda _event, pr, _files, _jobs: 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() + + +@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 417e891..4369792 100644 --- a/tests/test_supply_chain_policy.py +++ b/tests/test_supply_chain_policy.py @@ -264,6 +264,38 @@ 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 '--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"' + 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 + + def test_readme_describes_current_transfer_and_log_rotation_contracts(): readme = (ROOT / 'README.md').read_text(encoding='utf-8')