diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d7d730..2a22135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,13 @@ name: CI on: - pull_request: + # Push to main, non-draft pull requests and the weekly schedule run this through + # pipeline.yml (`workflow_call`), in parallel with the other checks and behind its `gate` job. + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + workflow_dispatch: permissions: actions: read @@ -9,7 +15,7 @@ permissions: pull-requests: write concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6d5de97 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +# CodeQL code scanning for every language in the repo that supports `build-mode: none`; +# results land in the Security tab. Repo default setup must stay off — +# GitHub rejects advanced-workflow uploads while it is on. +name: codeql + +on: + # Push to main, non-draft pull requests and the weekly schedule run this through + # pipeline.yml (`workflow_call`), in parallel with the other checks and behind its `gate` job. + workflow_call: + workflow_dispatch: + +jobs: + analyze: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + strategy: + fail-fast: false + matrix: + # `build-mode: none` for all: every language is extracted from source, so no build runs here. + # Not scanned: swift (no `build-mode: none` support), java-kotlin (Kotlin only, and + # none mode extracts Java only), ruby (only a CocoaPods Podfile, no Ruby sources), python + # (only vendored WAMR test scripts). + language: [actions, c-cpp, javascript-typescript, rust] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: none + queries: security-and-quality + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..63cad6c --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,89 @@ +# Gated pipeline. ci, codeql, semgrep and snyk run in parallel as reusable workflows; `gate` +# passes only when all four succeeded. Anything downstream (deploy, release, publish) must +# `needs: gate`. Draft pull requests skip every job, the gate included, until marked ready. +# Secrets are passed explicitly (no `secrets: inherit`): each child gets only what it uses. +name: pipeline + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +concurrency: + group: pipeline-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + ci: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + uses: ./.github/workflows/ci.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # ci.yml's jobs request these (e.g. revert-on-failure); a called workflow cannot exceed them. + permissions: + actions: read + contents: read + pull-requests: write + + codeql: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + uses: ./.github/workflows/codeql.yml + permissions: + security-events: write + contents: read + actions: read + + semgrep: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + uses: ./.github/workflows/semgrep.yml + permissions: + security-events: write + contents: read + actions: read + + snyk: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + uses: ./.github/workflows/snyk.yml + secrets: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + permissions: + security-events: write + contents: read + actions: read + + gate: + needs: [ci, codeql, semgrep, snyk] + # !cancelled(): run even when a need failed, was cancelled or skipped, so the gate reports it, + # but not when the whole run is cancelled (always() would hold the run and its concurrency + # group until a runner picks up the gate). Skipped on draft PRs like every other job, so a + # draft run never leaves a green `gate` check behind. + if: ${{ !cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} + runs-on: ubuntu-latest + steps: + - name: Require every job to succeed + env: + R_CI: ${{ needs.ci.result }} + R_CODEQL: ${{ needs.codeql.result }} + R_SEMGREP: ${{ needs.semgrep.result }} + R_SNYK: ${{ needs.snyk.result }} + run: | + # Only `success` passes: failure, cancelled and skipped all fail the gate. + fail=0 + for pair in "ci=$R_CI" "codeql=$R_CODEQL" "semgrep=$R_SEMGREP" "snyk=$R_SNYK"; do + name=${pair%%=*} + result=${pair#*=} + echo "$name: $result" + if [ "$result" != "success" ]; then + echo "::error title=gate::$name finished with '$result'" + fail=1 + fi + done + exit "$fail" diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..55e4ccb --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,32 @@ +# Semgrep static analysis with the free open-source CLI and the community `p/default` registry +# ruleset (no Semgrep account or token). Results land in the Security tab (Code scanning, +# category `semgrep`). Findings never fail the job, so this workflow does not block CI. +name: semgrep + +on: + # Push to main, non-draft pull requests and the weekly schedule run this through + # pipeline.yml (`workflow_call`), in parallel with the other checks and behind its `gate` job. + workflow_call: + workflow_dispatch: + +jobs: + scan: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + container: + # semgrep/semgrep:1.177.0 + image: semgrep/semgrep@sha256:acaac22ffc7b7cc5926de0751b223bce0b2491c33d18422fa72f632c78d81198 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # No `--error`: findings are reported, not enforced. Metrics off since no account is used. + - name: Semgrep scan + run: semgrep scan --config p/default --sarif --output semgrep.sarif --metrics off + - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + if: always() + with: + sarif_file: semgrep.sarif + category: semgrep diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml new file mode 100644 index 0000000..d44be71 --- /dev/null +++ b/.github/workflows/snyk.yml @@ -0,0 +1,70 @@ +# Snyk Open Source dependency scan with the free Snyk CLI. Results land in the Security tab +# (Code scanning, category `snyk`); findings never fail the job. Runs from pipeline.yml. +# +# Needs the repo secret SNYK_TOKEN. Without it every step is skipped with a notice and the job +# succeeds, so the pipeline gate stays green until the token is added. +# +# Ecosystems in this repo: Cargo (Rust), pnpm/npm workspaces, Gradle Kotlin DSL (Android), Swift Package Manager (Package.resolved), CocoaPods (Podfile), pip (requirements.txt), Dockerfiles. +# pnpm/npm, SwiftPM and Gradle are attempted; pip needs its packages installed and CocoaPods needs Podfile.lock, so those may show as unresolved. Dockerfiles are not scanned (`snyk container test` needs built images). +# Rust/Cargo: Snyk documents that `snyk test` / `snyk monitor` do not support Cargo projects +# (Rust is limited to `snyk sbom test` on a CycloneDX/SPDX SBOM, and API use needs a paid plan), +# so `--all-projects` does not cover Cargo.lock here. +name: snyk + +on: + workflow_call: + secrets: + SNYK_TOKEN: + required: false + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + actions: read + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + steps: + - name: Check for SNYK_TOKEN + id: token + run: | + if [ -z "$SNYK_TOKEN" ]; then + echo "::notice title=snyk skipped::SNYK_TOKEN secret is not set; Snyk scan skipped." + echo "present=false" >> "$GITHUB_OUTPUT" + else + echo "present=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.token.outputs.present == 'true' + - uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 + if: steps.token.outputs.present == 'true' + # Exit 1 = vulnerabilities found (reported, not enforced); 2/3 = scan error or no + # supported manifest (warned, not enforced). + - name: Snyk test + if: steps.token.outputs.present == 'true' + run: | + code=0 + snyk test --all-projects --sarif-file-output=snyk.sarif || code=$? + case "$code" in + 0) ;; + 1) echo "::notice title=snyk::Vulnerable dependencies found; see Security > Code scanning (category snyk)." ;; + *) echo "::warning title=snyk::snyk test exited $code (scan error or no supported project); not failing the job." ;; + esac + - name: Snyk monitor + if: steps.token.outputs.present == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + code=0 + snyk monitor --all-projects || code=$? + if [ "$code" != 0 ]; then echo "::warning title=snyk::snyk monitor exited $code"; fi + - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + if: steps.token.outputs.present == 'true' && hashFiles('snyk.sarif') != '' + with: + sarif_file: snyk.sarif + category: snyk