diff --git a/.github/workflows/pipeline-compliance.yml b/.github/workflows/pipeline-compliance.yml new file mode 100644 index 0000000..2b1df67 --- /dev/null +++ b/.github/workflows/pipeline-compliance.yml @@ -0,0 +1,26 @@ +name: Pipeline compliance + +on: + push: + branches: [main] + pull_request: null + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + security-events: write + +jobs: + compliance: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: ./pipeline-compliance diff --git a/pipeline-compliance/action.yml b/pipeline-compliance/action.yml new file mode 100644 index 0000000..4a1567a --- /dev/null +++ b/pipeline-compliance/action.yml @@ -0,0 +1,86 @@ +name: 'Pipeline compliance scan' +description: 'Run pipeline compliance scan (gitleaks + supply-chain governance)' + +inputs: + plumber-threshold: + description: 'Plumber compliance threshold (0-100)' + required: false + default: '80' + plumber-config-file: + description: 'Path to .plumber.yaml config file' + required: false + upload-sarif: + description: 'Upload SARIF results to GitHub Security tab' + required: false + default: ${{ github.event.repository.visibility == 'public' }} + github-token: + description: 'Github Token' + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Install gitleaks + shell: bash + env: + GITLEAKS_VERSION: v8.24.0 + GITLEAKS_SHA256: cb49b7de5ee986510fe8666ca0273a6cc15eb82571f2f14832c9e8920751f3a4 + run: | + tarball="gitleaks_${GITLEAKS_VERSION#v}_linux_x64.tar.gz" + curl -sSL --retry 3 --retry-delay 2 --connect-timeout 10 --max-time 60 "https://github.com/gitleaks/gitleaks/releases/download/${GITLEAKS_VERSION}/${tarball}" -o "$tarball" + echo "${GITLEAKS_SHA256} ${tarball}" | sha256sum -c - + mkdir -p "$HOME/.local/bin" + + tar xzf "$tarball" -C "/tmp" gitleaks + mv /tmp/gitleaks $HOME/.local/bin/gitleaks_unwrapped + cat > "$HOME/.local/bin/gitleaks" <<'GITLEAKS_SCRIPT' + #!/bin/bash + args=() + skip_next=false + + for arg in "${@:1}"; do + if $skip_next; then + # Previous argument was "--source" (separate form): replace this value + args+=("$SOURCE_VALUE") + skip_next=false + continue + fi + + case "$arg" in + --source=*) + # Form: --source=oldvalue => --source=$SOURCE_VALUE + args+=("--source=$SOURCE_VALUE") + ;; + --source) + # Form: --source oldvalue => --source $SOURCE_VALUE + args+=("--source") + skip_next=true + ;; + *) + args+=("$arg") + ;; + esac + done + + exec "$HOME/.local/bin/gitleaks_unwrapped" "${args[@]}" + GITLEAKS_SCRIPT + + chmod +x "$HOME/.local/bin/gitleaks" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/gitleaks" version + + - name: Computing config path + shell: bash + run: | + path=${{ github.action_path }}/plumber.yaml + echo "plumber-config-file=$path" >> $GITHUB_ENV + + - uses: getplumber/plumber@1e15c4a702fb2bff00992cc2171aafdf50909b87 # v0.3.50 + with: + threshold: ${{ inputs.plumber-threshold }} + config-file: ${{ inputs.plumber-config-file || env.plumber-config-file }} + upload-sarif: ${{ inputs.upload-sarif }} + github-token: ${{ inputs.github-token }} + env: + SOURCE_VALUE: ${{ github.workspace }} + diff --git a/pipeline-compliance/gitleaks.toml b/pipeline-compliance/gitleaks.toml new file mode 100644 index 0000000..6cf3454 --- /dev/null +++ b/pipeline-compliance/gitleaks.toml @@ -0,0 +1,32 @@ +title = "Outscale Gitleaks configuration" + +[extend] +useDefault = true +disabledRules = [ "generic-api-key"] + +[[rules]] +id = "osc-access-key" +regex = '''(?:^|[^A-Za-z0-9])([A-Z0-9]{20})(?:[^A-Za-z0-9]|$)''' +secretGroup = 1 +entropy = 2.5 + +[rules.allowlists] +regexTarget = "match" +regexes = [ +'''0123456789ABCDEFGHIJ''', +'''ABCDEFGHIJ0123456789''', +'''11112211111110000000''', +] + +[[rules]] +id = "osc-secret-key" +regex = '''(?:^|[^A-Za-z0-9])([A-Z0-9]{40})(?:[^A-Za-z0-9]|$)''' +secretGroup = 1 +entropy = 3.5 + +[rules.allowlists] +regexTarget = "match" +regexes = [ +'''0000001111112222223333334444445555555666''', +] + diff --git a/pipeline-compliance/plumber.yaml b/pipeline-compliance/plumber.yaml new file mode 100644 index 0000000..c4549ca --- /dev/null +++ b/pipeline-compliance/plumber.yaml @@ -0,0 +1,411 @@ +# 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: + controls: + # ── Pipeline must not leak secrets in configuration ───────────── + # + # Runs gitleaks against every workflow file under + # .github/workflows/ to detect hardcoded secrets (API tokens, + # private keys, passwords) committed directly into the YAML. + # Requires gitleaks to be installed and available on PATH (or + # configured via gitleaksPath). Disabled by default because + # gitleaks is an external dependency. + pipelineMustNotLeakSecretsInConfig: + # Set to true to enable this control (requires gitleaks) + enabled: true + # Path to the gitleaks binary. Defaults to "gitleaks" (PATH lookup). + # gitleaksPath: /usr/local/bin/gitleaks + # Optional path to a custom gitleaks configuration file. + # gitleaksConfigPath: /path/to/gitleaks.toml + gitleaksConfigPath: /home/runner/work/outscale/.github/pipline-compliance/gitleaks.toml + + # =========================================== + # 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 + trustedOwners: + - outscale + + # =========================================== + # 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 + + # =========================================== + # Pipeline must not execute unverified scripts + # =========================================== + # Same control as GitLab (ISSUE-411). Scans every workflow `run:` step + # for pipe-to-shell, download-then-exec, redirect-then-exec, and Megalodon- + # style base64 obfuscation. Maps to OWASP CICD-SEC-3 / CICD-SEC-8. + pipelineMustNotExecuteUnverifiedScripts: + enabled: true + trustedUrls: [] + # - https://internal-artifacts.example.com/* + + # =========================================== + # 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 + + # =========================================== + # pull_request_target must not check out PR head + # =========================================== + # Flags a workflow triggered by `pull_request_target` that checks + # out the pull-request head (github.event.pull_request.head.sha or + # github.head_ref). Base-repo secrets and fork-controlled code then + # run together, which is the tj-actions / CVE-2025-30066 vector. + # Keep fork code under a plain `pull_request` trigger instead. + pullRequestTargetMustNotCheckoutHead: + 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: false + # required: myorg/sast-scan AND myorg/policy/.github/workflows/scan.yml + 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-801). 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 +