From 39bb3cf04d8690dcd532f9a67606f187fabb8503 Mon Sep 17 00:00:00 2001 From: mark Date: Thu, 10 Sep 2026 16:32:24 +0000 Subject: [PATCH 1/3] Add poutine supply-chain scan for GitHub Actions workflows poutine (https://github.com/boostsecurityio/poutine) is a CI pipeline scanner: it looks for supply-chain weaknesses in the workflows themselves (untrusted checkout execution, injection from external contributor input, secrets exposure, self-hosted runners on PRs, unpinnable components, known-vulnerable build components). It complements zizmor rather than duplicating it. - scripts/poutine-scan.sh runs the pinned poutine image against the repo read-only with no network access, emits a pretty report plus SARIF, and gates on POUTINE_FAIL_LEVEL (default: warning). - .github/workflows/poutine.yml runs it on workflow changes, weekly for rule/advisory drift, and on demand; SARIF goes to code scanning on push, and to the job summary on pull requests (forks cannot upload). - .poutine.yml suppresses nothing today; note-level findings are reported without blocking merges. Current state of the repo: zero warning/error findings. The seven note-level 'action from unverified creator' findings are all already commit-SHA pinned. --- .github/workflows/poutine.yml | 75 +++++++++++++++++++++++++++++++++++ .gitignore | 3 ++ .poutine.yml | 18 +++++++++ scripts/poutine-scan.sh | 61 ++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 .github/workflows/poutine.yml create mode 100644 .poutine.yml create mode 100755 scripts/poutine-scan.sh diff --git a/.github/workflows/poutine.yml b/.github/workflows/poutine.yml new file mode 100644 index 000000000..868a4644f --- /dev/null +++ b/.github/workflows/poutine.yml @@ -0,0 +1,75 @@ +name: Pipeline supply-chain analysis + +on: + push: + branches: ['main'] + paths: + - '.github/workflows/**' + - '.github/actions/**' + - '.poutine.yml' + - 'scripts/poutine-scan.sh' + pull_request: + branches: ['**'] + paths: + - '.github/workflows/**' + - '.github/actions/**' + - '.poutine.yml' + - 'scripts/poutine-scan.sh' + schedule: + # Weekly drift check: new poutine rules / advisories can flag workflows + # that were clean when they were last touched. + - cron: '17 6 * * 1' + workflow_dispatch: + +concurrency: + group: poutine-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + poutine: + name: Run poutine + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # to upload SARIF results to code scanning + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run poutine + env: + # Notes (e.g. actions from unverified creators) are reported to code + # scanning but do not gate CI; warnings and errors do. + POUTINE_FAIL_LEVEL: warning + run: | + { + echo '## poutine' + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + set +e + ./scripts/poutine-scan.sh 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" + status="${PIPESTATUS[0]}" + set -e + echo '```' >>"$GITHUB_STEP_SUMMARY" + exit "$status" + + - name: Upload SARIF to code scanning + # Only on push/schedule: PRs from forks cannot push SARIF results, so + # findings surface in the job summary above instead. + if: ${{ always() && github.event_name != 'pull_request' }} + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + with: + sarif_file: poutine.sarif + category: poutine + + - name: Upload SARIF artifact + if: ${{ always() }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: poutine-sarif + path: poutine.sarif + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 905492c66..d90d4883e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ build/ # NCMEC report XML files (contain sensitive data) ncmec-reports/ .devops/backups/ + +# poutine supply-chain scan report (see scripts/poutine-scan.sh) +poutine.sarif diff --git a/.poutine.yml b/.poutine.yml new file mode 100644 index 000000000..5ab6306fa --- /dev/null +++ b/.poutine.yml @@ -0,0 +1,18 @@ +# poutine configuration. +# Auto-discovered at the repo root; see also ./scripts/poutine-scan.sh and +# .github/workflows/poutine.yml. + +# The scan runs offline in CI; no need for the daily release check. +disableVersionCheck: true + +# Nothing is suppressed today: note-level findings (currently only +# `github_action_from_unverified_creator_used`, all of which are commit-SHA +# pinned) are reported to code scanning but do not gate CI, because +# poutine-scan.sh gates at level >= warning. +# +# To permanently accept a specific note instead, add it here, e.g.: +# skip: +# - rule: github_action_from_unverified_creator_used +# purl: +# - pkg:githubactions/dorny/paths-filter +skip: [] diff --git a/scripts/poutine-scan.sh b/scripts/poutine-scan.sh new file mode 100755 index 000000000..b57be586d --- /dev/null +++ b/scripts/poutine-scan.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# Supply-chain analysis of this repository's CI pipelines (GitHub Actions) +# using poutine . +# +# Read-only by design: the repo is bind-mounted into a pinned container as +# ro, the container has no network access, and the only file written is the +# SARIF report (default: poutine.sarif, gitignored / uploaded as an artifact). +# +# Usage: +# ./poutine-scan.sh # pretty report + SARIF, fail on warning/error +# POUTINE_FAIL_LEVEL=none ./poutine-scan.sh +# POUTINE_FAIL_LEVEL=note ./poutine-scan.sh +# POUTINE_SARIF=/tmp/out.sarif ./poutine-scan.sh +# +# Env: +# POUTINE_IMAGE pinned poutine image (digest-pinned; bump deliberately) +# POUTINE_FAIL_LEVEL none | note | warning | error (default: warning) +# POUTINE_SARIF SARIF output path (default: poutine.sarif) +# +set -euo pipefail + +POUTINE_IMAGE="${POUTINE_IMAGE:-ghcr.io/boostsecurityio/poutine:1.1.6@sha256:722a8e0999b583c1540fe2974e691032b2d9d21b9256a17965132b6bfd0081b0}" +POUTINE_FAIL_LEVEL="${POUTINE_FAIL_LEVEL:-warning}" +POUTINE_SARIF="${POUTINE_SARIF:-poutine.sarif}" + +repo_root="${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}" + +for bin in docker jq; do + command -v "$bin" >/dev/null || { echo "poutine-scan: '$bin' is required" >&2; exit 2; } +done + +poutine() { + docker run --rm --network none \ + -v "${repo_root}:/src:ro" -w /src \ + "$POUTINE_IMAGE" analyze_local /src \ + --disable-version-check --quiet "$@" +} + +# Human-readable report (stdout) + machine-readable report (SARIF). +poutine +poutine --format sarif >"$POUTINE_SARIF" + +case "$POUTINE_FAIL_LEVEL" in + none) echo "poutine-scan: report written to ${POUTINE_SARIF} (not gating)"; exit 0 ;; + note) levels='["note","warning","error"]' ;; + warning) levels='["warning","error"]' ;; + error) levels='["error"]' ;; + *) echo "poutine-scan: invalid POUTINE_FAIL_LEVEL='${POUTINE_FAIL_LEVEL}'" >&2; exit 2 ;; +esac + +blocking="$(jq --argjson levels "$levels" \ + '[.runs[0].results[]? | select(((.level // "warning")) as $l | $levels | index($l))] | length' \ + "$POUTINE_SARIF")" + +if [ "$blocking" -gt 0 ]; then + echo "poutine-scan: ${blocking} finding(s) at level >= ${POUTINE_FAIL_LEVEL} (see ${POUTINE_SARIF})" >&2 + exit 1 +fi + +echo "poutine-scan: no findings at level >= ${POUTINE_FAIL_LEVEL} (report: ${POUTINE_SARIF})" From 4406cecd20e044514665c4ed6c27e9c98640adab Mon Sep 17 00:00:00 2001 From: mark Date: Thu, 10 Sep 2026 16:52:26 +0000 Subject: [PATCH 2/3] Use the official poutine-action instead of a hand-rolled scan script Replaces scripts/poutine-scan.sh with boostsecurityio/poutine-action, per upstream's recommended integration. - Pinned to main@badd750a rather than the latest tag (v1.1.4): that tag still ships poutine 1.1.4, while this commit ships 1.1.6, the same image digest the previous script pinned. - The action always exits 0, so a small gate step reads the SARIF and fails the job on warning/error findings only; note-level findings are still reported without blocking merges. - The action runs twice (pretty + sarif) because it emits one format per invocation: pretty goes to the job summary, SARIF to code scanning. No change in results: still zero warning/error findings. --- .github/workflows/poutine.yml | 52 ++++++++++++++++++++--------- .gitignore | 3 +- .poutine.yml | 6 ++-- scripts/poutine-scan.sh | 61 ----------------------------------- 4 files changed, 42 insertions(+), 80 deletions(-) delete mode 100755 scripts/poutine-scan.sh diff --git a/.github/workflows/poutine.yml b/.github/workflows/poutine.yml index 868a4644f..a151ae7e6 100644 --- a/.github/workflows/poutine.yml +++ b/.github/workflows/poutine.yml @@ -7,14 +7,12 @@ on: - '.github/workflows/**' - '.github/actions/**' - '.poutine.yml' - - 'scripts/poutine-scan.sh' pull_request: branches: ['**'] paths: - '.github/workflows/**' - '.github/actions/**' - '.poutine.yml' - - 'scripts/poutine-scan.sh' schedule: # Weekly drift check: new poutine rules / advisories can flag workflows # that were clean when they were last touched. @@ -40,22 +38,44 @@ jobs: with: persist-credentials: false - - name: Run poutine + # Pinned to main@badd750a rather than the v1.1.4 tag: the latest tag + # still ships poutine 1.1.4, while this commit ships 1.1.6. Bump + # deliberately when upstream cuts a tag for it. + - name: poutine - GitHub Actions SAST (human-readable) + uses: boostsecurityio/poutine-action@badd750a259150be3b8b00823ad60849d539b96d # main @ poutine 1.1.6 + with: + format: pretty + output: poutine.txt + + - name: poutine - GitHub Actions SAST (SARIF) + uses: boostsecurityio/poutine-action@badd750a259150be3b8b00823ad60849d539b96d # main @ poutine 1.1.6 + with: + format: sarif + output: poutine.sarif + + # The action always exits 0, so gate here. Notes (e.g. actions from + # unverified creators) are reported to code scanning but do not fail + # CI; warnings and errors do. + - name: Report and gate on findings env: - # Notes (e.g. actions from unverified creators) are reported to code - # scanning but do not gate CI; warnings and errors do. - POUTINE_FAIL_LEVEL: warning + FAIL_LEVEL_REGEX: '^(warning|error)$' run: | { echo '## poutine' echo '```' + cat poutine.txt + echo '```' } >>"$GITHUB_STEP_SUMMARY" - set +e - ./scripts/poutine-scan.sh 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" - status="${PIPESTATUS[0]}" - set -e - echo '```' >>"$GITHUB_STEP_SUMMARY" - exit "$status" + + blocking="$(jq --arg re "$FAIL_LEVEL_REGEX" \ + '[.runs[0].results[]? | select((.level // "warning") | test($re))] | length' \ + poutine.sarif)" + + if [ "$blocking" -gt 0 ]; then + echo "::error::poutine found ${blocking} finding(s) at warning level or above" + exit 1 + fi + echo "poutine: no findings at warning level or above" - name: Upload SARIF to code scanning # Only on push/schedule: PRs from forks cannot push SARIF results, so @@ -66,10 +86,12 @@ jobs: sarif_file: poutine.sarif category: poutine - - name: Upload SARIF artifact + - name: Upload poutine reports if: ${{ always() }} uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: poutine-sarif - path: poutine.sarif + name: poutine-reports + path: | + poutine.sarif + poutine.txt if-no-files-found: warn diff --git a/.gitignore b/.gitignore index d90d4883e..e2093bb2c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,6 @@ build/ ncmec-reports/ .devops/backups/ -# poutine supply-chain scan report (see scripts/poutine-scan.sh) +# poutine supply-chain scan reports (.github/workflows/poutine.yml) poutine.sarif +poutine.txt diff --git a/.poutine.yml b/.poutine.yml index 5ab6306fa..8c98d0762 100644 --- a/.poutine.yml +++ b/.poutine.yml @@ -2,13 +2,13 @@ # Auto-discovered at the repo root; see also ./scripts/poutine-scan.sh and # .github/workflows/poutine.yml. -# The scan runs offline in CI; no need for the daily release check. +# No need for the daily release check in CI. disableVersionCheck: true # Nothing is suppressed today: note-level findings (currently only # `github_action_from_unverified_creator_used`, all of which are commit-SHA -# pinned) are reported to code scanning but do not gate CI, because -# poutine-scan.sh gates at level >= warning. +# pinned) are reported to code scanning but do not gate CI. The workflow +# gates at warning level and above. # # To permanently accept a specific note instead, add it here, e.g.: # skip: diff --git a/scripts/poutine-scan.sh b/scripts/poutine-scan.sh deleted file mode 100755 index b57be586d..000000000 --- a/scripts/poutine-scan.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# -# Supply-chain analysis of this repository's CI pipelines (GitHub Actions) -# using poutine . -# -# Read-only by design: the repo is bind-mounted into a pinned container as -# ro, the container has no network access, and the only file written is the -# SARIF report (default: poutine.sarif, gitignored / uploaded as an artifact). -# -# Usage: -# ./poutine-scan.sh # pretty report + SARIF, fail on warning/error -# POUTINE_FAIL_LEVEL=none ./poutine-scan.sh -# POUTINE_FAIL_LEVEL=note ./poutine-scan.sh -# POUTINE_SARIF=/tmp/out.sarif ./poutine-scan.sh -# -# Env: -# POUTINE_IMAGE pinned poutine image (digest-pinned; bump deliberately) -# POUTINE_FAIL_LEVEL none | note | warning | error (default: warning) -# POUTINE_SARIF SARIF output path (default: poutine.sarif) -# -set -euo pipefail - -POUTINE_IMAGE="${POUTINE_IMAGE:-ghcr.io/boostsecurityio/poutine:1.1.6@sha256:722a8e0999b583c1540fe2974e691032b2d9d21b9256a17965132b6bfd0081b0}" -POUTINE_FAIL_LEVEL="${POUTINE_FAIL_LEVEL:-warning}" -POUTINE_SARIF="${POUTINE_SARIF:-poutine.sarif}" - -repo_root="${GITHUB_WORKSPACE:-$(git rev-parse --show-toplevel)}" - -for bin in docker jq; do - command -v "$bin" >/dev/null || { echo "poutine-scan: '$bin' is required" >&2; exit 2; } -done - -poutine() { - docker run --rm --network none \ - -v "${repo_root}:/src:ro" -w /src \ - "$POUTINE_IMAGE" analyze_local /src \ - --disable-version-check --quiet "$@" -} - -# Human-readable report (stdout) + machine-readable report (SARIF). -poutine -poutine --format sarif >"$POUTINE_SARIF" - -case "$POUTINE_FAIL_LEVEL" in - none) echo "poutine-scan: report written to ${POUTINE_SARIF} (not gating)"; exit 0 ;; - note) levels='["note","warning","error"]' ;; - warning) levels='["warning","error"]' ;; - error) levels='["error"]' ;; - *) echo "poutine-scan: invalid POUTINE_FAIL_LEVEL='${POUTINE_FAIL_LEVEL}'" >&2; exit 2 ;; -esac - -blocking="$(jq --argjson levels "$levels" \ - '[.runs[0].results[]? | select(((.level // "warning")) as $l | $levels | index($l))] | length' \ - "$POUTINE_SARIF")" - -if [ "$blocking" -gt 0 ]; then - echo "poutine-scan: ${blocking} finding(s) at level >= ${POUTINE_FAIL_LEVEL} (see ${POUTINE_SARIF})" >&2 - exit 1 -fi - -echo "poutine-scan: no findings at level >= ${POUTINE_FAIL_LEVEL} (report: ${POUTINE_SARIF})" From 1a843e7d166fd7d48e3f7f5bc0d861cb510d5a81 Mon Sep 17 00:00:00 2001 From: mark Date: Thu, 10 Sep 2026 17:50:55 +0000 Subject: [PATCH 3/3] Apply review feedback from the zizmor rollout Carries over the three points raised on coop#721: - Pin the scanner binary, not just the action: the pinned action commit builds FROM poutine:1.1.6@sha256:722a8e09..., so the version cannot drift under a fixed action SHA (this is why it is pinned to main@badd750a and not @main as upstream's README suggests). - Upload SARIF only on push, annotate on pull requests: PR runs now emit inline file/line annotations from the SARIF instead of relying on the job summary alone. - No standing suppressions to maintain: .poutine.yml is gone. Its only content was disableVersionCheck, now set via the POUTINE_DISABLE_VERSION_CHECK env var on the scan steps, so there is no config file that could quietly grow exceptions. Also adds the CHANGELOG entry under 'CI & infrastructure'. --- .github/workflows/poutine.yml | 46 +++++++++++++++++++++++------------ .poutine.yml | 18 -------------- CHANGELOG.md | 4 +++ 3 files changed, 35 insertions(+), 33 deletions(-) delete mode 100644 .poutine.yml diff --git a/.github/workflows/poutine.yml b/.github/workflows/poutine.yml index a151ae7e6..62d634743 100644 --- a/.github/workflows/poutine.yml +++ b/.github/workflows/poutine.yml @@ -6,13 +6,11 @@ on: paths: - '.github/workflows/**' - '.github/actions/**' - - '.poutine.yml' pull_request: branches: ['**'] paths: - '.github/workflows/**' - '.github/actions/**' - - '.poutine.yml' schedule: # Weekly drift check: new poutine rules / advisories can flag workflows # that were clean when they were last touched. @@ -38,27 +36,46 @@ jobs: with: persist-credentials: false - # Pinned to main@badd750a rather than the v1.1.4 tag: the latest tag - # still ships poutine 1.1.4, while this commit ships 1.1.6. Bump - # deliberately when upstream cuts a tag for it. + # Pinned to main@badd750a rather than the v1.1.4 tag: that tag still + # builds FROM poutine 1.1.4, while this commit builds FROM + # poutine:1.1.6@sha256:722a8e09..., so both the action and the scanner + # binary are pinned. Bump deliberately when upstream tags the 1.1.6 base. - name: poutine - GitHub Actions SAST (human-readable) uses: boostsecurityio/poutine-action@badd750a259150be3b8b00823ad60849d539b96d # main @ poutine 1.1.6 + env: + POUTINE_DISABLE_VERSION_CHECK: '1' with: format: pretty output: poutine.txt - name: poutine - GitHub Actions SAST (SARIF) uses: boostsecurityio/poutine-action@badd750a259150be3b8b00823ad60849d539b96d # main @ poutine 1.1.6 + env: + POUTINE_DISABLE_VERSION_CHECK: '1' with: format: sarif output: poutine.sarif + - name: Annotate pull request with findings + # PRs from forks cannot upload SARIF to code scanning, so surface + # findings as inline annotations instead. + if: ${{ github.event_name == 'pull_request' }} + run: | + jq -r ' + .runs[0].results[]? + | (.level // "warning") as $level + | (if $level == "error" then "error" + elif $level == "note" then "notice" + else "warning" end) as $kind + | .locations[0].physicalLocation as $loc + | "::\($kind) file=\($loc.artifactLocation.uri),line=\($loc.region.startLine // 1),title=poutine: \(.ruleId)::" + + (.message.text | gsub("\\s+"; " ") | .[0:400]) + ' poutine.sarif + # The action always exits 0, so gate here. Notes (e.g. actions from - # unverified creators) are reported to code scanning but do not fail - # CI; warnings and errors do. + # unverified creators) are reported but do not fail CI; warnings and + # errors do. - name: Report and gate on findings - env: - FAIL_LEVEL_REGEX: '^(warning|error)$' run: | { echo '## poutine' @@ -67,19 +84,18 @@ jobs: echo '```' } >>"$GITHUB_STEP_SUMMARY" - blocking="$(jq --arg re "$FAIL_LEVEL_REGEX" \ - '[.runs[0].results[]? | select((.level // "warning") | test($re))] | length' \ - poutine.sarif)" + blocking="$(jq ' + [.runs[0].results[]? | select((.level // "warning") | test("^(warning|error)$"))] + | length + ' poutine.sarif)" if [ "$blocking" -gt 0 ]; then - echo "::error::poutine found ${blocking} finding(s) at warning level or above" + echo "::error title=poutine::${blocking} finding(s) at warning level or above" exit 1 fi echo "poutine: no findings at warning level or above" - name: Upload SARIF to code scanning - # Only on push/schedule: PRs from forks cannot push SARIF results, so - # findings surface in the job summary above instead. if: ${{ always() && github.event_name != 'pull_request' }} uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: diff --git a/.poutine.yml b/.poutine.yml deleted file mode 100644 index 8c98d0762..000000000 --- a/.poutine.yml +++ /dev/null @@ -1,18 +0,0 @@ -# poutine configuration. -# Auto-discovered at the repo root; see also ./scripts/poutine-scan.sh and -# .github/workflows/poutine.yml. - -# No need for the daily release check in CI. -disableVersionCheck: true - -# Nothing is suppressed today: note-level findings (currently only -# `github_action_from_unverified_creator_used`, all of which are commit-SHA -# pinned) are reported to code scanning but do not gate CI. The workflow -# gates at warning level and above. -# -# To permanently accept a specific note instead, add it here, e.g.: -# skip: -# - rule: github_action_from_unverified_creator_used -# purl: -# - pkg:githubactions/dorny/paths-filter -skip: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b315c3b6..15fdd4484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ For more information about each release including git tags and artifacts, see [R - Passwords are hashed with Argon2id instead of bcrypt at cost factor 5 ([#901](https://github.com/roostorg/coop/pull/901) by [@serendipty01](https://github.com/serendipty01), closes [#900](https://github.com/roostorg/coop/issues/900)) - Minimum password length raised to 15 and enforced server-side ([#1065](https://github.com/roostorg/coop/pull/1065), [#1094](https://github.com/roostorg/coop/pull/1094) by [@serendipty01](https://github.com/serendipty01)) +### CI & infrastructure + +- `poutine` added to scan CI workflows for supply-chain vulnerabilities, gated on warning-level findings and reporting to code scanning ([#1156](https://github.com/roostorg/coop/pull/1156) by [@reitblatt](https://github.com/reitblatt)) + ## [1.0.2] - 2026-06-30 This release addresses reported security advisories, improves NCMEC CyberTipline reporting, and includes front-end quality-of-life improvements.