From 355fbc8906522a3366cd8be5ee913425bff4dc97 Mon Sep 17 00:00:00 2001 From: Thomas Boni Date: Thu, 21 May 2026 10:12:35 +0200 Subject: [PATCH] ci: fix workflows security --- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 4 +- .plumber.yaml | 379 ++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 .plumber.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3bcfde..de94a67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: with: fetch-depth: 0 - name: Install Helm - uses: azure/setup-helm@v3 + uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 with: version: 3.10.1 - name: Install Python @@ -34,20 +34,24 @@ jobs: with: python-version: 3.11 - name: Install chart-testing - uses: helm/chart-testing-action@v2.3.1 + uses: helm/chart-testing-action@afea100a513515fbd68b0e72a7bb0ae34cb62aec # v2.3.1 - name: Add dependency chart repositories run: ./scripts/add_helm_repo.sh - name: List changed charts id: list-changed + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }}) + changed=$(ct list-changed --target-branch "$DEFAULT_BRANCH") charts=$(echo "$changed" | tr '\n' ' ' | xargs) if [[ -n "$changed" ]]; then echo "changed=true" >> $GITHUB_OUTPUT echo "changed_charts=$charts" >> $GITHUB_OUTPUT fi - name: Lint charts - run: ct lint --target-branch ${{ github.event.repository.default_branch }} + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ct lint --target-branch "$DEFAULT_BRANCH" unit-tests: name: Unit tests runs-on: ubuntu-latest @@ -58,6 +62,10 @@ jobs: charts: charts/plumber flags: --color -o test-results.xml --output-type JUnit helm-version: v3.10.1 + # helm-unittest v1.1.0 (2026-05-08) switched plugin.yaml to the + # platformHooks/platformCommand schema, which Helm 3.10.1 cannot + # parse. Pin to the last v1.x release using the legacy schema. + unittest-version: v1.0.3 - name: Test Report uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1 if: ${{ !cancelled() }} # run this step even if previous step failed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61da39f..c858ad3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,13 +22,13 @@ jobs: git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - name: Install Helm - uses: azure/setup-helm@v3 + uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 with: version: 3.10.1 - name: Add dependency chart repositories run: ./scripts/add_helm_repo.sh - name: Run chart-releaser - uses: helm/chart-releaser-action@v1.7.0 + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 env: CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" CR_RELEASE_NAME_TEMPLATE: "v{{ .Version }}" diff --git a/.plumber.yaml b/.plumber.yaml new file mode 100644 index 0000000..ae7f72b --- /dev/null +++ b/.plumber.yaml @@ -0,0 +1,379 @@ +# Plumber Configuration - Trust Policy Manager for GitLab CI/CD +# This file is required for 'plumber analyze' to work. +# Default file name: .plumber.yaml +# Reference: https://github.com/getplumber/plumber/blob/main/.plumber.yaml +# Can be generated with: plumber config generate + +version: "2.0" + +# ============================================================================ +# GitHub Actions controls +# ============================================================================ +github: + controls: + # =========================================== + # Actions must be pinned by commit SHA + # =========================================== + # Flags workflow steps whose `uses:` reference is not a 40-character + # commit SHA. Tag/branch refs (v4, main) are mutable: if the action's + # maintainer is compromised or retags a release, the caller workflow + # silently runs new code with its secrets. This is the vector behind + # the March 2025 tj-actions/changed-files compromise (CVE-2025-30066). + # + # The default trustedOwners list exempts first-party (`actions/*`, + # `github/*`) actions so the initial signal on a fresh repo stays + # focused on the third-party surface. Pair with Dependabot + # (`version-update-strategy: sha-and-version`) to keep pins fresh. + actionsMustBePinnedByCommitSha: + enabled: true + # Action-owner prefixes exempt from the pin-by-SHA requirement. + # Only list owners already inside the workflow's trust boundary. + trustedOwners: + - actions + - github + + # =========================================== + # Container images must not use forbidden tags + # =========================================== + # Same control as GitLab; values are GitHub-side. Pinning by digest + # protects against tag-retag supply-chain attacks. The forbidden tag + # list catches the common cases of mutable references. + containerImageMustNotUseForbiddenTags: + enabled: true + tags: + - latest + - dev + - development + - staging + - main + - master + # When true, ALL images must be pinned by digest (e.g., + # alpine@sha256:...). Takes precedence over the forbidden tags + # list — any image not using an immutable digest reference is + # flagged. + containerImagesMustBePinnedByDigest: true + + # =========================================== + # Pipeline must not use Docker-in-Docker + # =========================================== + # Workflows on GitHub-hosted runners that spin up `docker:dind` + # services have the same privilege-escalation risk as on GitLab. + # detectInsecureDaemon also flags plaintext DOCKER_HOST and empty + # DOCKER_TLS_CERTDIR values. + pipelineMustNotUseDockerInDocker: + enabled: true + detectInsecureDaemon: true + + # =========================================== + # Reusable workflows must not inherit secrets + # =========================================== + # Detects `jobs..secrets: inherit` calls. Inherit forwards + # every secret visible to the caller — repo, organisation, + # environment — to the reusable workflow regardless of what it + # actually needs. Use an explicit secrets map instead. + reusableWorkflowsMustNotInheritSecrets: + enabled: true + + # =========================================== + # Security jobs must not be weakened + # =========================================== + # Jobs matching the security-scanner naming patterns must not be + # neutralised via `continue-on-error: true` (mapped to the same + # IR field as GitLab's allow_failure: true) or manual-dispatch-only + # triggers. The pattern set covers the GitHub-native scanners + # users actually run — `codeql`, `dependency-review`, `trufflehog`, + # `gitleaks`, `osv-scanner`, plus generic fallbacks like + # `*scan*`, `*audit*`, `*security*` so SAST jobs named with + # project-specific prefixes still match. + securityJobsMustNotBeWeakened: + enabled: true + # How patterns are matched + # ──────────────────────── + # Each pattern is a glob (`*` matches any substring) compared + # against the job name plumber builds for every job in your + # workflows. That name is: + # + # / + # + # Examples: + # .github/workflows/codeql-analysis.yml + jobs.analyze + # -> codeql-analysis/analyze + # .github/workflows/ci.yml + jobs.lint + # -> ci/lint + # .github/workflows/workflow.yml + jobs.my-sast + # -> workflow/my-sast + # + # The namespace exists so two workflow files defining a job + # with the same id do not collide. Patterns can target whichever + # part of that name is stable for your repo: + # "**" token anywhere in the name (resilient) + # "/*" every job in one workflow file + # "*/" specific job id, any workflow + # "/" exact match, no wildcard + # + # The defaults below ship wildcard-wrapped because plumber does + # not know your repo's workflow-file convention. If your jobs + # follow a known layout you can drop the wildcards for tighter + # matching. + securityJobPatterns: + - "*codeql*" + - "*dependency-review*" + - "*trufflehog*" + - "*gitleaks*" + - "*osv-scanner*" + - "*-sast" + - "*-sast-*" + - "*-scan" + - "*scan*" + - "*-security" + - "*-security-*" + - "*-audit" + - "*-audit-*" + # Real-world slash-form examples (commented; uncomment / adapt + # to your repo for a tighter match than the wildcards above). + # Format reminder: /. + # + # - codeql-analysis/analyze # GitHub's default CodeQL template + # - dependency-review/dependency-review # GitHub's default Dependency Review template + # - security/gitleaks # gitleaks job in security.yml + # - security/trufflehog # trufflehog job in security.yml + # - security/* # every job in security.yml + # - "*/sast" # any job named "sast", any workflow + # - ci/semgrep-sast # exact match, no wildcard + allowFailureMustBeFalse: + enabled: true + rulesMustNotBeRedefined: + enabled: true + whenMustNotBeManual: + enabled: true + + # =========================================== + # Workflow must not inject user input in scripts + # =========================================== + # Catches the canonical script-injection class: `${{ github.event.* }}` + # / `${{ github.head_ref }}` / `${{ github.actor }}` interpolated + # directly into a `run:` shell. Attacker-controlled values like PR + # title or branch name can break out of the intended string and + # execute arbitrary commands with the job's secrets. Bind through + # `env:` first, then reference the env var from the shell. + workflowMustNotInjectUserInputInScripts: + enabled: true + + # =========================================== + # Workflow must not use dangerous triggers + # =========================================== + # Flags `pull_request_target` and `workflow_run` triggers, which + # run with the base repository's secrets while being influenceable + # by an unprivileged caller. Combined with any user-content + # checkout this becomes a direct exfiltration path (CVE-2025-30066). + # Use the standard `pull_request` trigger unless secrets are + # required. + workflowMustNotUseDangerousTriggers: + enabled: true + + # =========================================== + # Workflows must declare permissions + # =========================================== + # Workflows without an explicit `permissions:` block fall back to + # the repo-wide GITHUB_TOKEN default — often `contents: write` or + # `read-all`. Declaring `permissions: { contents: read }` at the + # workflow level enforces least-privilege regardless of the repo + # default. + workflowsMustDeclarePermissions: + enabled: true + + # =========================================== + # Branch must be protected (project governance) + # =========================================== + # The first project-governance control on the GitHub path. Every + # other shipping rule is pipeline-governance (workflow content); + # this one inspects repository settings via the GitHub branch- + # protection API. Requires a token with `repo` scope (classic + # PAT) or "Administration: read" (fine-grained PAT) — content- + # only read access is NOT enough. Without scope the collector + # returns an empty branch list and the rule emits no findings, + # same degraded contract as missing API auth elsewhere. + branchMustBeProtected: + enabled: true + defaultMustBeProtected: true + namePatterns: + - main + - master + - release/* + - production + - dev + allowForcePush: false + codeOwnerApprovalRequired: false + + # ============================================================================ + # Workflows must include required actions + # ============================================================================ + # Asserts that every workflow file under .github/workflows/ + # collectively references a set of required actions or reusable + # workflows. The GitHub counterpart of + # pipelineMustIncludeComponent / pipelineMustIncludeTemplate on + # the GitLab side. + # + # GitHub has two ways to "include" something external; the + # control treats both the same so you do not have to declare + # which is which: + # + # Step-level action: + # steps: + # - uses: myorg/sast-scan@v2 + # + # Job-level reusable workflow call: + # jobs: + # security: + # uses: myorg/policy/.github/workflows/scan.yml@v2 + # + # Each required entry is an `owner/repo[/path]` string. Matching + # is ref-agnostic so bumping a pinned SHA does not invalidate + # the policy. A slash-guard prevents accidental prefix + # collisions: `myorg/sast-scan` matches + # `myorg/sast-scan@` and + # `myorg/sast-scan/sub@`, but NOT + # `myorg/sast-scan-fork@`. + # + # Two ways to define requirements (use one, not both): + # + # Option 1, Expression syntax ('required'): + # A natural boolean expression using AND, OR, and parentheses. + # AND binds tighter than OR, so "a AND b OR c" means + # "(a AND b) OR c". + # + # required: myorg/sast-scan AND myorg/dependency-review + # required: (myorg/sast-scan AND myorg/secret-scan) OR myorg/full-security-suite + # + # Option 2, Array syntax ('requiredGroups'): + # A list of groups using "OR of ANDs" logic: + # - Each inner array = items that must ALL be present (AND) + # - Outer array = only ONE group needs to be satisfied (OR) + # + # requiredGroups: + # - ["myorg/sast-scan", "myorg/dependency-review"] + # - ["myorg/full-security-suite"] + # + # Disabled by default; opt in once your organization has settled + # on the action set every repo is expected to wire up. + workflowMustIncludeRequiredActions: + enabled: true + required: "actions/checkout AND azure/setup-helm AND actions/setup-python" + requiredGroups: [] + + # =========================================== + # Workflow must not grant write-all permissions + # =========================================== + # Flags workflows and jobs whose effective `permissions:` block is the + # literal `write-all` shortcut. write-all gives GITHUB_TOKEN every + # scope (contents, packages, deployments, id-token, …) at the same + # time, so any compromise in the workflow — a malicious dependency, + # a script-injection bug, a third-party action turning evil — gets + # to do anything the repo allows: push to main, publish releases, + # mint OIDC tokens for cloud accounts, mark deployments succeeded. + # + # Workflow-level `permissions: write-all` is propagated to every + # job by the GitHub Actions runner; the rule reads the per-job + # effective permissions, so a workflow-level grant is caught the + # same way as a job-level one. + # + # Scope: static `permissions:` in committed workflow YAML only. + # Flags the literal string shortcut `write-all` on a job's effective + # permissions (workflow-level `write-all` is propagated to every job). + # + # Does not flag: `permissions: { contents: write, … }` maps, `read-all`, + # or missing `permissions:` (see `workflowsMustDeclarePermissions` / + # ISSUE-304). Does not evaluate `${{ }}` expressions or permissions + # inside callee reusable-workflow files Plumber did not fetch. + # + # Pair with `workflowsMustDeclarePermissions` for the full story. + workflowMustNotGrantPermissionsWriteAll: + enabled: true + + # =========================================== + # Actions must not reference archived repositories + # =========================================== + # Flags `uses: owner/repo@ref` references whose upstream repository + # is archived on GitHub. Archived repos no longer receive + # maintenance: open vulnerabilities stay open, dependency bumps + # stop, runtime compatibility regressions accumulate. Pinning by + # SHA does not save the caller — the last maintainer (or someone + # who later acquires the namespace) can still push new code under + # the original repository name. + # + # Scope: committed `.github/workflows/*.{yml,yaml}` only; step-level + # `uses: owner/repo@ref` (not reusable-workflow `jobs.*.uses`, not + # local `./.github/actions/*`). Requires GitHub API auth; without it + # the rule abstains (no finding). + # + # How it works: `GET /repos/{owner}/{repo}` → `archived` flag, one + # cached lookup per `owner/repo` (all refs share the same result). + # + # Does not see: callee reusable-workflow files, deleted/private repos + # (lookup may fail silently → abstain). + actionsMustNotBeArchived: + enabled: true + + # =========================================== + # Actions must not carry known CVEs + # =========================================== + # Cross-references every `uses: owner/repo@ref` against the GitHub + # Advisory Database under the `actions` ecosystem. A positive hit + # means at least one published advisory targets the action's + # repository. This is the rule that catches the published-CVE + # supply-chain class — tj-actions/changed-files (CVE-2025-30066), + # reviewdog/action-setup (March 2025), unpatched versions of + # `actions/artifact`. + # + # Scope: committed `.github/workflows/*.{yml,yaml}` only; step-level + # `uses: owner/repo@ref` (not reusable-workflow `jobs.*.uses`, not + # `./.github/actions/*`, not `docker://`). Requires GitHub API auth + # (`gh` / GH_TOKEN); without it the rule abstains (no finding). + # + # How it works: one Advisory Database query per `owner/repo` + # (`ecosystem=actions`), cached. When the pinned ref resolves to a + # semver tag, Plumber filters advisories by `vulnerable_version_range`. + # When the ref is an unresolvable commit SHA, any advisory for that + # repo may match (conservative). Upgrade past the fixed-in version and + # re-pin the SHA to clear the finding. + # + # Does not see: org/repo Variables, runtime `$GITHUB_ENV` writes, or + # actions nested inside composite actions you do not call directly. + actionsMustNotCarryKnownCVEs: + enabled: true + + # =========================================== + # Pipeline must not enable debug trace + # =========================================== + # GitHub-side parallel of the GitLab `pipelineMustNotEnableDebugTrace` + # control. Flags workflows or jobs that set the GitHub Actions + # debug-trace toggles to a truthy value (`true`, `1`, `yes`). + # + # When `ACTIONS_STEP_DEBUG=true` or `ACTIONS_RUNNER_DEBUG=true`, + # the runner prints every environment variable (including masked + # secrets) and every internal action SDK call into the job log. + # The masking layer is bypassed for the dump itself, so any + # secret consumed by the workflow lands in plaintext in the run + # log and is then visible to anyone with `actions: read` on the + # repository, plus indefinitely on log artefacts. + # + # Scope: static `env:` in committed `.github/workflows/*.{yml,yaml}`. + # The collector merges workflow-, job-, and step-level `env:` into + # each job (step > job > workflow precedence on name collisions). + # + # Truthy values: `true`, `1`, `yes` (case-insensitive, trimmed). + # Variable names: case-insensitive match against `forbiddenVariables`. + # + # Also flags: `${{ }}` values on forbidden names in static `env:` + # (cannot verify off statically), and `run:` lines that write a + # forbidden name to `$GITHUB_ENV`. All ISSUE-203 findings are critical. + # + # Does not see: org/repo/environment Variables with no YAML reference, + # env inside callee reusable workflows Plumber did not fetch, or GitHub + # UI "Re-run with debug logging" (not in YAML). + pipelineMustNotEnableDebugTrace: + enabled: true + forbiddenVariables: + - ACTIONS_STEP_DEBUG + - ACTIONS_RUNNER_DEBUG +