Security #999
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Security | |
| # Dependency-vulnerability scan (cargo-audit) + license/advisory/source policy (cargo-deny). | |
| # | |
| # Pure-documentation pushes are skipped: they cannot change Cargo.lock or any `.rs` code, so | |
| # there is nothing for these jobs to find. The weekly `schedule` cron still re-runs the full | |
| # suite, so a NEW advisory published against an unchanged dependency is still caught even when no | |
| # code is pushed. | |
| on: | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - "**/*.md" | |
| - "docs/**" | |
| - "ref-docs/**" | |
| - "to-dos/**" | |
| - "LICENSE-*" | |
| - ".gitignore" | |
| pull_request: | |
| branches: [main] | |
| paths-ignore: | |
| - "**/*.md" | |
| - "docs/**" | |
| - "ref-docs/**" | |
| - "to-dos/**" | |
| - "LICENSE-*" | |
| - ".gitignore" | |
| schedule: | |
| - cron: "0 0 * * 1" | |
| workflow_dispatch: | |
| inputs: | |
| fuzz_seconds: | |
| description: "Seconds per fuzz target (the weekly schedule uses 300)" | |
| required: false | |
| default: "300" | |
| concurrency: | |
| group: security-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| # Least-privilege default, inherited by EVERY job below (`audit`, `deny`, `fuzz`) — none of them | |
| # needs more, and none declares its own block, so this is the effective grant for all three. | |
| # `audit`/`deny` only read the checked-out tree and the lockfiles; `fuzz` additionally uploads a | |
| # crash artifact on failure, which `actions/upload-artifact` does through the Actions API rather | |
| # than the repo, so it needs no `contents: write` either. | |
| permissions: | |
| contents: read | |
| jobs: | |
| audit: | |
| name: Dependency Audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| # Install the PREBUILT binary, never `cargo install` (build-from-source): the repo pins | |
| # rustc 1.96, but a from-source cargo-audit build can need a newer toolchain than that to | |
| # COMPILE. The prebuilt binary runs fine under any toolchain -- it only parses Cargo.lock, | |
| # it never compiles this project. | |
| - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2 | |
| with: | |
| tool: cargo-audit | |
| - run: cargo audit | |
| # `fuzz/` is its OWN workspace with its OWN lock (see `fuzz/README.md` for why), so the | |
| # command above — which resolves the root workspace — never sees a single one of its 422 | |
| # dependencies. That gap is easy to miss precisely because the audit job goes green either | |
| # way. `--file` scans the second lock explicitly. | |
| # | |
| # Worth auditing despite being dev-only tooling: a fuzz target links `libfuzzer-sys` and runs | |
| # attacker-shaped input by design, and an advisory against something in that graph is exactly | |
| # the kind of thing that would otherwise sit unnoticed until someone read the lock by hand. | |
| - run: cargo audit --file fuzz/Cargo.lock | |
| deny: | |
| name: Cargo Deny Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| # Same rationale as the audit job: the prebuilt binary avoids needing a newer toolchain | |
| # than the repo's pinned 1.96 just to build the checker itself. Policy lives in `deny.toml`. | |
| - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2 | |
| with: | |
| tool: cargo-deny | |
| - run: cargo deny check | |
| # `fuzz/`'s separate workspace is deliberately NOT checked here, unlike the audit job above | |
| # which does scan its lock. `libfuzzer-sys` is `(MIT OR Apache-2.0) AND NCSA` — the NCSA | |
| # coming from the LLVM libFuzzer runtime it vendors — and NCSA is not on this project's | |
| # allow-list. The two ways to make this pass are both worse than the omission: | |
| # | |
| # * allowing NCSA in `deny.toml` loosens the policy for the SHIPPED artifact to accommodate | |
| # a dev-only tool that is never built into, linked against, or distributed with any | |
| # release binary; | |
| # * a second `fuzz/deny.toml` would gate bans/sources/licenses on tooling nobody ships, | |
| # while the one genuinely security-relevant half — advisories — is already covered by | |
| # `cargo audit --file fuzz/Cargo.lock`. | |
| # | |
| # Recorded rather than silently skipped so the gap is a decision, not an oversight. | |
| fuzz: | |
| name: Fuzz Campaign | |
| # SCHEDULED ONLY, never on push or pull_request. Per-commit fuzzing finds almost nothing — a | |
| # 60-second campaign re-treads the corpus it already has — while adding ~15 minutes to every | |
| # PR. The value is in long campaigns against an accumulating corpus, so this runs weekly on the | |
| # cron above, or on demand. | |
| # | |
| # The per-PR guard that *does* belong on the critical path is the compile gate in `ci.yml`'s | |
| # `lint` job: it catches a fuzz target rotting out of buildability, which is the failure mode | |
| # that would otherwise make this job silently useless. | |
| if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| # Fourteen targets at five minutes each, plus a cold ~10-minute build of the frontend's wgpu | |
| # stack. The ceiling is generous rather than tight: a job killed mid-campaign reports failure | |
| # indistinguishably from a real finding. | |
| timeout-minutes: 180 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| # cargo-fuzz needs nightly (`-Z sanitizer=address`); the project pins 1.96 stable in | |
| # `rust-toolchain.toml`. Installing nightly alongside it, and invoking it explicitly as | |
| # `+nightly`, keeps the pin authoritative for every other job. | |
| - uses: dtolnay/rust-toolchain@4fd1da8b0805d2d2e936788875a7d65dbd677dc2 # nightly | |
| - uses: taiki-e/install-action@6a1bd70eaac3c8bdf093356838d7ee09fda951cf # v2 | |
| with: | |
| tool: cargo-fuzz | |
| # `fuzz/` depends on `rustysnes-frontend` (the patch/preset/config/symbols boundaries live | |
| # there), which links the winit/wgpu/cpal stack — so this job needs the same system libraries | |
| # `ci.yml`'s frontend jobs do. | |
| - name: Install frontend system dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends \ | |
| libasound2-dev libudev-dev libxkbcommon-dev pkg-config | |
| # Validated before use rather than interpolated into the shell: `workflow_dispatch` inputs are | |
| # attacker-controlled in the same sense any repository input is, and this one reaches a | |
| # command line. A non-numeric value is rejected outright instead of being quoted and hoped | |
| # over. The schedule path supplies no input at all, hence the default. | |
| - name: Resolve campaign length | |
| id: length | |
| env: | |
| REQUESTED: ${{ github.event.inputs.fuzz_seconds }} | |
| run: | | |
| seconds="${REQUESTED:-300}" | |
| case "$seconds" in | |
| '' | *[!0-9]*) | |
| echo "::error::fuzz_seconds must be a positive integer, got '$seconds'" | |
| exit 1 | |
| ;; | |
| esac | |
| if [ "$seconds" -lt 10 ] || [ "$seconds" -gt 3600 ]; then | |
| echo "::error::fuzz_seconds must be between 10 and 3600, got '$seconds'" | |
| exit 1 | |
| fi | |
| echo "seconds=$seconds" >>"$GITHUB_OUTPUT" | |
| # `run.sh` seeds each corpus from the committed permissive ROM corpus and applies the | |
| # dictionaries. Both matter: unseeded, `rom_header` plateaus around 29 edges because header | |
| # detection scores candidate offsets and a random image never scores above zero — the | |
| # `$xFD8` unbounded-shift defect this infrastructure found was unreachable without seeding. | |
| - name: Run campaign | |
| env: | |
| SECONDS_PER_TARGET: ${{ steps.length.outputs.seconds }} | |
| run: bash fuzz/run.sh "$SECONDS_PER_TARGET" | |
| # Only on failure, and this is the whole point of the job: a crashing input is worthless if it | |
| # dies with the runner. Reproduce locally with | |
| # cargo +nightly fuzz run <target> <artifact> | |
| - name: Upload crashing inputs | |
| if: failure() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: fuzz-artifacts | |
| path: | | |
| fuzz/artifacts/ | |
| fuzz/target/*.campaign.log | |
| retention-days: 30 | |
| if-no-files-found: warn |