diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df092755..e3096352 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,13 @@ on: permissions: contents: read +concurrency: + group: wardnet-ci-${{ github.repository }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: rust: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ebcb3ce9..135966fd 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -18,20 +18,15 @@ permissions: contents: read concurrency: - group: fuzz-${{ github.ref }} - cancel-in-progress: true + group: wardnet-fuzz-${{ github.repository }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'schedule' && format('schedule-{0}', github.ref) || format('run-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: fuzz: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: - - fuzz_score_request - - fuzz_appdata_json - - fuzz_parse_admin_tokens - - fuzz_dnsbl_zone + # One runner covers every bounded target; a matrix would consume four org slots. + runs-on: ubuntu-24.04 + env: + FUZZ_SECONDS: ${{ github.event_name == 'pull_request' && '60' || '300' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -46,30 +41,25 @@ jobs: path: | ~/.cargo/bin/cargo-fuzz fuzz/target - key: cargo-fuzz-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('fuzz/Cargo.toml') }} + key: cargo-fuzz-0.13.2-${{ runner.os }}-${{ hashFiles('fuzz/Cargo.toml') }} - name: Install cargo-fuzz - run: cargo +nightly install cargo-fuzz --version 0.13.2 --locked || true + run: command -v cargo-fuzz >/dev/null || cargo +nightly install cargo-fuzz --version 0.13.2 --locked - # PRs: 60s per target. Nightly/manual: 300s per target. - - name: Set fuzz duration + - name: Fuzz bounded targets run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "FUZZ_SECONDS=60" >> "$GITHUB_ENV" - else - echo "FUZZ_SECONDS=300" >> "$GITHUB_ENV" - fi - - - name: Fuzz ${{ matrix.target }} - run: | - cargo +nightly fuzz run ${{ matrix.target }} -- \ - -max_total_time="$FUZZ_SECONDS" \ - -rss_limit_mb=2048 + exit_code=0 + for target in fuzz_score_request fuzz_appdata_json fuzz_parse_admin_tokens fuzz_dnsbl_zone; do + cargo +nightly fuzz run "$target" -- \ + -max_total_time="$FUZZ_SECONDS" \ + -rss_limit_mb=2048 || exit_code=$? + done + exit "$exit_code" - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: fuzz-artifacts-${{ matrix.target }} - path: fuzz/artifacts/${{ matrix.target }}/ + name: fuzz-artifacts + path: fuzz/artifacts/ if-no-files-found: ignore diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index dfa64206..2d147bee 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -13,7 +13,7 @@ permissions: jobs: analysis: name: Scorecard Analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read security-events: write diff --git a/AGENTS.md b/AGENTS.md index 2a32694b..ae81252e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ Cross-agent conventions for any agent (Claude, Codex, Cursor, opencode, …) wor - Do **not** weaken or disable the gate. A local scan with a stale DB misses findings: run `trivy --download-db-only` first, then scan the **merge ref**, not just the PR head (e.g. `trivy fs --scanners vuln,misconfig --severity CRITICAL,HIGH --ignore-unfixed .`). - Gating is by the Security Scan **job result**, not the `code_scanning` rule. That org ruleset is intentionally **CodeQL-only** (multiple code-scanning tools can't converge on one PR ref) — do **not** add tools to it. +### GitHub Actions ownership + +- Organization ruleset `18156473` owns Wardnet PR review, governance, security, and CodeQL through the seven required workflows in `ContextualWisdomLab/.github@769691526f8c73cf714de8fe8ba51ae6cfa2901a`. Do not add local PR Governance, Dependency Review, Close Empty PR, CodeQL, Security Scan, SAST, Strix, OpenCode, or Noema copies. +- Keep repository-local `CI` and path-filtered `Fuzz` because they validate Wardnet's Rust code. Their PR concurrency keys must include a fixed workflow name, `github.repository`, and the PR number; only superseded heads of that PR may be cancelled. +- Keep the local Scorecard default-branch, weekly, and branch-protection workflow until the central reusable owner matches Wardnet's pinned Scorecard version. The compared central revision uses Scorecard v2.4.3 while Wardnet uses v2.4.4, so delegating now would weaken deployed security evidence. + ### Code exploration - There is no `.codegraph/` index in this repo, so use normal search (grep/ripgrep, `cargo` tooling, editor navigation). If a `.codegraph/` index is added later, prefer CodeGraph (`codegraph explore ""` or the code-review-graph MCP tools) before grep/find — it surfaces callers/callees/impact that text search misses. diff --git a/tests/workflow_queue_contract.rs b/tests/workflow_queue_contract.rs new file mode 100644 index 00000000..0e1c4659 --- /dev/null +++ b/tests/workflow_queue_contract.rs @@ -0,0 +1,67 @@ +use std::{fs, path::PathBuf}; + +fn workflow_text(name: &str) -> String { + fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(".github/workflows") + .join(name), + ) + .unwrap() +} + +#[test] +fn local_pr_workflows_cancel_only_superseded_heads_of_the_same_pull_request() { + for (name, fixed_group) in [("ci.yml", "wardnet-ci"), ("fuzz.yml", "wardnet-fuzz")] { + let workflow = workflow_text(name); + assert!(workflow.contains(&format!( + "group: {fixed_group}-${{{{ github.repository }}}}-${{{{ github.event_name == 'pull_request'" + ))); + assert!(workflow.contains("format('pr-{0}', github.event.pull_request.number)")); + assert!( + workflow.contains("cancel-in-progress: ${{ github.event_name == 'pull_request' }}") + ); + } +} + +#[test] +fn fuzz_uses_one_runner_for_all_bounded_targets() { + let workflow = workflow_text("fuzz.yml"); + assert!(!workflow.contains("\n strategy:\n")); + assert!(!workflow.contains("Set fuzz duration")); + for target in [ + "fuzz_score_request", + "fuzz_appdata_json", + "fuzz_parse_admin_tokens", + "fuzz_dnsbl_zone", + ] { + assert!(workflow.contains(target)); + } +} + +#[test] +fn central_required_workflows_are_not_copied_locally() { + let workflow_directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".github/workflows"); + for name in [ + "pr-governance.yml", + "dependency-review.yml", + "close-empty-pr.yml", + "codeql-pr.yml", + "security-scan.yml", + "sast-semgrep.yml", + "strix.yml", + "opencode-review.yml", + "noema-review.yml", + ] { + assert!(!workflow_directory.join(name).exists()); + } +} + +#[test] +fn local_scorecard_preserves_non_pr_security_evidence() { + let workflow = workflow_text("scorecard-analysis.yml"); + assert!(workflow.contains("branch_protection_rule:")); + assert!(workflow.contains("schedule:")); + assert!(workflow.contains("push:")); + assert!(!workflow.contains("pull_request:")); + assert!(workflow.contains("# v2.4.4")); +} diff --git a/tests/workflow_runner_contract.rs b/tests/workflow_runner_contract.rs new file mode 100644 index 00000000..ff42bad4 --- /dev/null +++ b/tests/workflow_runner_contract.rs @@ -0,0 +1,49 @@ +//! Repository contract for deterministic GitHub-hosted runner selection. +//! +//! Wardnet's required pull-request workflows must not depend on GitHub's floating +//! `ubuntu-latest` alias. A floating image can change independently of the +//! repository and, during hosted-runner transitions, can leave exact-head jobs +//! queued before checkout. Pinning the Ubuntu image makes runner acquisition a +//! reviewed repository change while preserving GitHub-hosted execution. + +use std::fs; +use std::path::Path; + +const PINNED_UBUNTU_RUNNER: &str = "ubuntu-24.04"; +const FLOATING_UBUNTU_RUNNER: &str = "ubuntu-latest"; + +const RUNNER_BACKED_WORKFLOWS: &[&str] = &[ + ".github/workflows/ci.yml", + ".github/workflows/fuzz.yml", + ".github/workflows/scorecard-analysis.yml", +]; + +#[test] +fn runner_backed_workflows_pin_the_hosted_ubuntu_image() { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")); + + for relative in RUNNER_BACKED_WORKFLOWS { + let path = repository.join(relative); + let workflow = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + assert!( + !workflow.contains(FLOATING_UBUNTU_RUNNER), + "{relative} must not use the floating {FLOATING_UBUNTU_RUNNER} runner alias" + ); + + let runners = workflow + .lines() + .filter_map(|line| line.trim().strip_prefix("runs-on:")) + .map(str::trim) + .collect::>(); + assert!( + !runners.is_empty(), + "{relative} must define at least one runs-on value" + ); + assert!( + runners.iter().all(|runner| *runner == PINNED_UBUNTU_RUNNER), + "{relative} must use {PINNED_UBUNTU_RUNNER} for every runs-on value; found {runners:?}" + ); + } +}