diff --git a/.cursor/rules/reusable-workflows.mdc b/.cursor/rules/reusable-workflows.mdc index 203a84fa..3a3081fa 100644 --- a/.cursor/rules/reusable-workflows.mdc +++ b/.cursor/rules/reusable-workflows.mdc @@ -417,6 +417,92 @@ uses: some-action/tool@main # use a specific tag or SHA - Never print secrets via `echo`, env dumps, or step summaries - Complex conditional logic belongs in the workflow (not in composites) — full log visibility +### pull_request_target — NEVER checkout fork code + +- NEVER use `actions/checkout` with `ref: ${{ github.event.pull_request.head.ref }}` or `ref: ${{ github.event.pull_request.head.sha }}` in `pull_request_target` workflows +- If `pull_request_target` is needed (e.g., labeling, commenting), it MUST NOT run any code from the fork (no build, no test, no script execution from the PR branch) +- Prefer `pull_request` trigger over `pull_request_target` unless write permissions to the base repo are explicitly required +- NEVER use `secrets: inherit` in workflows triggered by `pull_request_target` — it exposes all repository secrets to fork code + +### Expression injection — sanitize ALL untrusted inputs + +NEVER use these directly in `run:` steps: + +```yaml +# ❌ All of these are injectable — NEVER interpolate directly in run: +${{ github.event.pull_request.title }} +${{ github.event.pull_request.body }} +${{ github.event.issue.title }} +${{ github.event.issue.body }} +${{ github.event.comment.body }} +${{ github.event.head_commit.message }} +${{ github.event.head_commit.author.name }} +${{ github.event.commits[*].message }} +${{ github.event.discussion.title }} +${{ github.event.discussion.body }} +${{ github.event.review.body }} +${{ github.head_ref }} +${{ github.event.pages[*].page_name }} +``` + +If any of these values are needed in a `run:` step, pass them through an environment variable: + +```yaml +# ✅ Safe — shell variable, not template injection +env: + PR_TITLE: ${{ github.event.pull_request.title }} +run: echo "$PR_TITLE" +``` + +### workflow_run — treat artifacts as untrusted + +- Workflows triggered by `workflow_run` MUST NOT trust artifacts from the triggering workflow blindly +- Validate/sanitize any data extracted from artifacts before use in shell commands or API calls +- Never extract and execute scripts from artifacts without verification + +### Permissions — principle of least privilege + +- ALWAYS declare explicit `permissions:` block at workflow level +- Default to `contents: read` — only escalate per-job when needed +- NEVER use `permissions: write-all` or leave permissions undeclared (defaults to broad access in public repos) +- For comment/label-only workflows: `permissions: { pull-requests: write }` — nothing else + +```yaml +# ✅ Explicit, minimal permissions +permissions: + contents: read + +jobs: + comment: + permissions: + pull-requests: write # escalated only for this job +``` + +### Secrets in fork contexts + +- NEVER pass secrets to steps that run fork code +- `pull_request` from a fork does NOT have access to secrets by default — do not circumvent this +- If a workflow needs secrets + fork code, split into two workflows: + - `pull_request` (no secrets, runs fork code) + - `workflow_run` (has secrets, runs trusted code only) + +### Script injection via labels/branches + +- Validate branch names and label names before using in shell commands +- Branch names can contain shell metacharacters — always quote variables + +```yaml +# ✅ Always quote branch/label variables +run: echo "$BRANCH_NAME" +env: + BRANCH_NAME: ${{ github.head_ref }} +``` + +### Self-hosted runners + +- NEVER use self-hosted runners for `pull_request` or `pull_request_target` from public repos — a fork can execute arbitrary code on the runner +- Self-hosted runners are only safe for `push`, `workflow_dispatch`, `schedule`, and other non-fork triggers + ### Reserved names — never use as custom secret or input names GitHub reserves the `GITHUB_*` prefix for all built-in variables and the `ACTIONS_*` prefix for runner internals. Declaring a custom secret or input with these names causes the job to fail silently or be ignored: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f8d3fc90..1f1ab84e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,14 +122,24 @@ on: description: 'Force multi-platform build (amd64+arm64) even for beta/rc tags' type: boolean default: false + docker_build_args: + description: 'Newline-separated Docker build arguments to pass to docker build (e.g., "APP_NAME=spi\nCOMPONENT_NAME=api"). Forwarded to docker/build-push-action build-args.' + type: string + required: false + default: '' build_context_from_working_dir: description: 'Use the component working_dir as Docker build context instead of build_context. Useful for independent modules (e.g., tools with their own go.mod).' type: boolean default: false + enable_cosign_sign: + description: 'Sign container images with cosign keyless (OIDC) signing after push. Requires id-token: write permission in the caller.' + type: boolean + default: true permissions: contents: read packages: write + id-token: write jobs: prepare: @@ -283,6 +293,7 @@ jobs: type=semver,pattern={{major}},value=${{ steps.version.outputs.version }},enable=${{ needs.prepare.outputs.is_release }} - name: Build and push Docker image + id: build-push uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 with: context: ${{ inputs.build_context_from_working_dir == true && matrix.app.working_dir || inputs.build_context }} @@ -291,6 +302,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: ${{ inputs.docker_build_args }} sbom: generator=docker/scout-sbom-indexer:latest provenance: mode=max cache-from: type=gha @@ -298,7 +310,42 @@ jobs: secrets: | github_token=${{ secrets.MANAGE_TOKEN }} - # GitOps artifacts for downstream gitops-update workflow + # ----------------- Cosign Image Signing ----------------- + - name: Build cosign image references + if: inputs.enable_cosign_sign + id: cosign-refs + env: + DIGEST: ${{ steps.build-push.outputs.digest }} + ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }} + ENABLE_GHCR: ${{ inputs.enable_ghcr }} + DOCKERHUB_ORG: ${{ inputs.dockerhub_org }} + APP_NAME: ${{ matrix.app.name }} + GHCR_ORG: ${{ steps.normalize.outputs.owner_lower }} + run: | + REFS="" + + if [ "$ENABLE_DOCKERHUB" == "true" ]; then + REFS="${DOCKERHUB_ORG}/${APP_NAME}@${DIGEST}" + fi + + if [ "$ENABLE_GHCR" == "true" ]; then + [ -n "$REFS" ] && REFS="${REFS}"$'\n' + REFS="${REFS}ghcr.io/${GHCR_ORG}/${APP_NAME}@${DIGEST}" + fi + + { + echo "refs<> "$GITHUB_OUTPUT" + + - name: Sign container images with cosign + if: inputs.enable_cosign_sign + uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@feat/cosign-sign + with: + image-refs: ${{ steps.cosign-refs.outputs.refs }} + + # ----------------- GitOps Artifacts ----------------- - name: Create GitOps tag artifact if: inputs.enable_gitops_artifacts run: | diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml index 9e0d6f9f..acd1ec86 100644 --- a/.github/workflows/go-release.yml +++ b/.github/workflows/go-release.yml @@ -67,10 +67,15 @@ on: description: 'Enable release notifications' type: boolean default: false + enable_cosign_sign: + description: 'Sign container images with cosign keyless (OIDC) signing after push. Requires id-token: write permission in the caller.' + type: boolean + default: true permissions: contents: write packages: write + id-token: write jobs: release: @@ -164,6 +169,7 @@ jobs: tags: ${{ inputs.docker_tags }} - name: Build and push + id: build-push uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 with: context: . @@ -174,6 +180,24 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # ----------------- Cosign Image Signing ----------------- + - name: Build cosign image references + if: inputs.enable_cosign_sign + id: cosign-refs + env: + DIGEST: ${{ steps.build-push.outputs.digest }} + DOCKER_REGISTRY: ${{ inputs.docker_registry }} + REPOSITORY: ${{ github.repository }} + run: | + REPO=$(echo "$REPOSITORY" | tr '[:upper:]' '[:lower:]') + echo "refs=${DOCKER_REGISTRY}/${REPO}@${DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Sign container images with cosign + if: inputs.enable_cosign_sign + uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@feat/cosign-sign + with: + image-refs: ${{ steps.cosign-refs.outputs.refs }} + # Slack notification notify: name: Notify diff --git a/.github/workflows/helm-update-chart.yml b/.github/workflows/helm-update-chart.yml index fae7ba2d..20cf0747 100644 --- a/.github/workflows/helm-update-chart.yml +++ b/.github/workflows/helm-update-chart.yml @@ -100,7 +100,7 @@ jobs: steps: - name: Generate GitHub App Token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} @@ -153,7 +153,7 @@ jobs: # triggered by internal dispatch, not a PR event. The ref is a controlled # branch name (develop/main), not an untrusted PR head. - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: ${{ steps.app-token.outputs.token }} ref: ${{ inputs.base_branch }} @@ -181,31 +181,36 @@ jobs: git config user.email "${GIT_USER_EMAIL}" - name: Create feature branch + env: + BRANCH_NAME: ${{ steps.payload.outputs.branch_name }} run: | - git checkout -b "${{ steps.payload.outputs.branch_name }}" + git checkout -b "${BRANCH_NAME}" - name: Setup Go if: ${{ inputs.update_readme }} - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: '1.21' cache-dependency-path: ${{ inputs.scripts_path }}/go.mod - name: Build scripts if: ${{ inputs.update_readme }} + env: + SCRIPTS_PATH: ${{ inputs.scripts_path }} run: | - cd ${{ inputs.scripts_path }} + cd "${SCRIPTS_PATH}" || exit 1 go build -o update-readme-matrix update-readme-matrix.go go build -o update-chart-version-readme update-chart-version-readme.go - name: Setup yq - uses: mikefarah/yq@5a7e72a743649b1b3a47d1a1d8214f3453173c51 # v4 + uses: mikefarah/yq@0f4fb8d35ec1a939d78dd6862f494d19ec589f19 # v4 - name: Process all components id: process + env: + CHART: ${{ steps.payload.outputs.chart }} + CHARTS_PATH: ${{ inputs.charts_path }} run: | - CHART="${{ steps.payload.outputs.chart }}" - CHARTS_PATH="${{ inputs.charts_path }}" VALUES_FILE="${CHARTS_PATH}/${CHART}/values.yaml" CHART_FILE="${CHARTS_PATH}/${CHART}/Chart.yaml" TEMPLATES_BASE="${CHARTS_PATH}/${CHART}/templates" @@ -272,7 +277,7 @@ jobs: echo "Processing components for chart: $CHART" # Process each component - for row in $(echo "$COMPONENTS" | jq -c '.[]'); do + while IFS= read -r row; do COMP_NAME=$(echo "$row" | jq -r '.name') COMP_VERSION=$(echo "$row" | jq -r '.version') COMP_ENV_VARS=$(echo "$row" | jq -c '.env_vars // {}') @@ -303,7 +308,7 @@ jobs: CONFIGMAP_FILE="${TEMPLATES_BASE}/${VALUES_KEY}/configmap.yaml" SECRET_FILE="${TEMPLATES_BASE}/${VALUES_KEY}/secret.yaml" - echo "$COMP_ENV_VARS" | jq -r 'to_entries[] | "\(.key)=\(.value)"' | while IFS='=' read -r key value; do + while IFS='=' read -r key value; do if [ -n "$key" ]; then # Escape values for safe sed insertion escaped_value=$(escape_sed "$value") @@ -332,7 +337,7 @@ jobs: fi fi fi - done + done < <(echo "$COMP_ENV_VARS" | jq -r 'to_entries[] | "\(.key)=\(.value)"') fi # Build updated components list for commit message @@ -341,7 +346,7 @@ jobs: else UPDATED_COMPONENTS="${COMP_NAME}@${COMP_VERSION}" fi - done + done < <(echo "$COMPONENTS" | jq -c '.[]') # Update appVersion with highest version among all components if [ -n "$HIGHEST_VERSION" ]; then @@ -355,35 +360,37 @@ jobs: - name: Update README matrix if: ${{ inputs.update_readme }} + env: + CHART: ${{ steps.payload.outputs.chart }} + CHARTS_PATH: ${{ inputs.charts_path }} + SCRIPTS_PATH: ${{ inputs.scripts_path }} run: | - CHART="${{ steps.payload.outputs.chart }}" - CHARTS_PATH="${{ inputs.charts_path }}" - SCRIPTS_PATH="${{ inputs.scripts_path }}" COMPONENTS=$(cat /tmp/components.json) # Get current appVersion from Chart.yaml APP_VERSION=$(yq '.appVersion' "${CHARTS_PATH}/${CHART}/Chart.yaml") # Update README for each component - for row in $(echo "$COMPONENTS" | jq -c '.[]'); do + while IFS= read -r row; do COMP_NAME=$(echo "$row" | jq -r '.name') COMP_VERSION=$(echo "$row" | jq -r '.version') echo "Updating README matrix for ${COMP_NAME}@${COMP_VERSION}" - ./${SCRIPTS_PATH}/update-readme-matrix \ + "./${SCRIPTS_PATH}/update-readme-matrix" \ --chart "${CHART}" \ --component "${COMP_NAME}" \ --version "${COMP_VERSION}" \ --app-version "${APP_VERSION}" - done + done < <(echo "$COMPONENTS" | jq -c '.[]') - name: Commit changes id: commit + env: + CHART: ${{ steps.payload.outputs.chart }} + UPDATED_COMPONENTS: ${{ steps.process.outputs.updated_components }} + HAS_NEW_ENV_VARS: ${{ steps.payload.outputs.has_new_env_vars }} run: | - CHART="${{ steps.payload.outputs.chart }}" - UPDATED_COMPONENTS="${{ steps.process.outputs.updated_components }}" - HAS_NEW_ENV_VARS="${{ steps.payload.outputs.has_new_env_vars }}" git add -A @@ -414,13 +421,12 @@ jobs: if: steps.commit.outputs.has_changes == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + CHART: ${{ steps.payload.outputs.chart }} + BRANCH_NAME: ${{ steps.payload.outputs.branch_name }} + BASE_BRANCH: ${{ inputs.base_branch }} + HAS_NEW_ENV_VARS: ${{ steps.payload.outputs.has_new_env_vars }} + UPDATED_COMPONENTS: ${{ steps.process.outputs.updated_components }} run: | - CHART="${{ steps.payload.outputs.chart }}" - BRANCH_NAME="${{ steps.payload.outputs.branch_name }}" - BASE_BRANCH="${{ inputs.base_branch }}" - COMMIT_MSG="${{ steps.commit.outputs.commit_msg }}" - HAS_NEW_ENV_VARS="${{ steps.payload.outputs.has_new_env_vars }}" - UPDATED_COMPONENTS="${{ steps.process.outputs.updated_components }}" # Push the branch git push -u origin "${BRANCH_NAME}" @@ -475,43 +481,53 @@ jobs: - name: Summary env: BASE_BRANCH: ${{ inputs.base_branch }} + CHART: ${{ steps.payload.outputs.chart }} + BRANCH_NAME: ${{ steps.payload.outputs.branch_name }} + HAS_CHANGES: ${{ steps.commit.outputs.has_changes }} run: | COMPONENTS=$(cat /tmp/components.json) - CHART="${{ steps.payload.outputs.chart }}" - BRANCH_NAME="${{ steps.payload.outputs.branch_name }}" - HAS_CHANGES="${{ steps.commit.outputs.has_changes }}" - - echo "### Helm Chart Update Summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "**Chart:** \`${CHART}\`" >> "$GITHUB_STEP_SUMMARY" - echo "**Branch:** \`${BRANCH_NAME}\`" >> "$GITHUB_STEP_SUMMARY" - echo "**Base:** \`${BASE_BRANCH}\`" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - - if [ "${HAS_CHANGES}" = "true" ]; then - echo "✅ **PR created successfully**" >> "$GITHUB_STEP_SUMMARY" - else - echo "ℹ️ **No changes detected**" >> "$GITHUB_STEP_SUMMARY" - fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "**Components:**" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Component | Version | New Env Vars |" >> "$GITHUB_STEP_SUMMARY" - echo "|-----------|---------|--------------|" >> "$GITHUB_STEP_SUMMARY" + { + echo "### Helm Chart Update Summary" + echo "" + echo "**Chart:** \`${CHART}\`" + echo "**Branch:** \`${BRANCH_NAME}\`" + echo "**Base:** \`${BASE_BRANCH}\`" + echo "" + + if [ "${HAS_CHANGES}" = "true" ]; then + echo "✅ **PR created successfully**" + else + echo "ℹ️ **No changes detected**" + fi + + echo "" + echo "**Components:**" + echo "" + echo "| Component | Version | New Env Vars |" + echo "|-----------|---------|--------------|" - echo "$COMPONENTS" | jq -r '.[] | "| \(.name) | \(.version) | \(.env_vars | if . == {} then "-" else (. | keys | join(", ")) end) |"' >> "$GITHUB_STEP_SUMMARY" + echo "$COMPONENTS" | jq -r '.[] | "| \(.name) | \(.version) | \(.env_vars | if . == {} then "-" else (. | keys | join(", ")) end) |"' + } >> "$GITHUB_STEP_SUMMARY" - name: Send Slack notification if: ${{ inputs.slack_notification && steps.commit.outputs.has_changes == 'true' }} + env: + CHART: ${{ steps.payload.outputs.chart }} + HAS_NEW_ENV_VARS: ${{ steps.payload.outputs.has_new_env_vars }} + SOURCE_REF: ${{ steps.payload.outputs.source_ref }} + SOURCE_REPO: ${{ steps.payload.outputs.source_repo }} + SOURCE_ACTOR: ${{ steps.payload.outputs.source_actor }} + SOURCE_SHA: ${{ steps.payload.outputs.source_sha }} + PR_URL: ${{ steps.push-pr.outputs.pr_url }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + WORKFLOW_NUM: ${{ github.run_number }} + BASE_BRANCH: ${{ inputs.base_branch }} + MENTION_GROUP: ${{ inputs.slack_mention_group || secrets.SLACK_GROUP_DEVOPS_SRE }} + SLACK_CHANNEL: ${{ inputs.slack_channel || secrets.SLACK_CHANNEL_DEVOPS }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_HELM }} + BOT_MENTION: ${{ inputs.slack_bot_mention || secrets.SLACK_BOT_SEVERINO }} run: | - CHART="${{ steps.payload.outputs.chart }}" - HAS_NEW_ENV_VARS="${{ steps.payload.outputs.has_new_env_vars }}" - SOURCE_REF="${{ steps.payload.outputs.source_ref }}" - SOURCE_REPO="${{ steps.payload.outputs.source_repo }}" - SOURCE_ACTOR="${{ steps.payload.outputs.source_actor }}" - SOURCE_SHA="${{ steps.payload.outputs.source_sha }}" - PR_URL="${{ steps.push-pr.outputs.pr_url }}" COMPONENTS=$(cat /tmp/components.json) # Get appVersion (highest version) @@ -526,12 +542,8 @@ jobs: # Build metadata TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M:%S UTC') - WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - WORKFLOW_NUM="${{ github.run_number }}" - BASE_BRANCH="${{ inputs.base_branch }}" - # Context with optional team mention (input takes precedence over org secret) - MENTION_GROUP="${{ inputs.slack_mention_group || secrets.SLACK_GROUP_DEVOPS_SRE }}" + # Context with optional team mention (set via env) if [ -n "$MENTION_GROUP" ]; then CONTEXT_TEXT=":clock1: ${TIMESTAMP} | Workflow: <${WORKFLOW_URL}|#${WORKFLOW_NUM}> | cc: " else @@ -544,8 +556,7 @@ jobs: {"type": "mrkdwn", "text": "*Version*"} ] + [.[] | {"type": "mrkdwn", "text": ("`" + .name + "`")}, {"type": "mrkdwn", "text": ("`" + .version + "`")}]') - # Get channel (input takes precedence over org secret) - SLACK_CHANNEL="${{ inputs.slack_channel || secrets.SLACK_CHANNEL_DEVOPS }}" + # Channel is set via env # Build complete payload using jq SLACK_PAYLOAD=$(jq -n \ @@ -595,7 +606,7 @@ jobs: # Send main notification to Slack via Bot API SLACK_RESPONSE=$(curl -s -X POST \ - -H "Authorization: Bearer ${{ secrets.SLACK_BOT_TOKEN_HELM }}" \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ -H "Content-type: application/json; charset=utf-8" \ --data "$SLACK_PAYLOAD" \ "https://slack.com/api/chat.postMessage") @@ -609,15 +620,18 @@ jobs: fi # Send separate message for Severino bot (Jira ticket creation) - # Input takes precedence over org secret - BOT_MENTION="${{ inputs.slack_bot_mention || secrets.SLACK_BOT_SEVERINO }}" if [ -n "$BOT_MENTION" ]; then SEVERINO_TEXT="<@${BOT_MENTION}> helm chart PR review | ${PR_URL} | Chart: ${CHART}" + SEVERINO_PAYLOAD=$(jq -n \ + --arg channel "${SLACK_CHANNEL}" \ + --arg text "${SEVERINO_TEXT}" \ + '{channel: $channel, text: $text}') + SEVERINO_RESPONSE=$(curl -s -X POST \ - -H "Authorization: Bearer ${{ secrets.SLACK_BOT_TOKEN_HELM }}" \ + -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ -H "Content-type: application/json; charset=utf-8" \ - --data "{\"channel\":\"${SLACK_CHANNEL}\",\"text\":\"${SEVERINO_TEXT}\"}" \ + --data "$SEVERINO_PAYLOAD" \ "https://slack.com/api/chat.postMessage") if echo "$SEVERINO_RESPONSE" | jq -e '.ok == true' > /dev/null; then diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 445503a5..3ead0722 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -67,6 +67,11 @@ on: description: 'Enable Docker Hub Health Score compliance checks (non-root user, CVEs, licenses)' type: boolean default: true + docker_build_args: + description: 'Newline-separated Docker build arguments to pass to docker build (e.g., "APP_NAME=spi\nCOMPONENT_NAME=api"). Forwarded to docker/build-push-action build-args.' + type: string + required: false + default: '' build_context_from_working_dir: description: 'Use the component working_dir as Docker build context instead of repo root. Useful for independent modules (e.g., tools with their own go.mod).' type: boolean @@ -161,6 +166,7 @@ jobs: load: true push: false tags: ${{ env.DOCKERHUB_ORG }}/${{ env.APP_NAME }}:pr-scan-${{ github.sha }} + build-args: ${{ inputs.docker_build_args }} secrets: | ${{ secrets.MANAGE_TOKEN && format('github_token={0}', secrets.MANAGE_TOKEN) || '' }} ${{ secrets.NPMRC_TOKEN && format('npmrc=//npm.pkg.github.com/:_authToken={0}', secrets.NPMRC_TOKEN) || '' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15eedebc..a26472c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,7 +150,7 @@ jobs: # ----------------- Snapshot tags before release ----------------- - name: Snapshot tags before release id: pre-tags - uses: LerianStudio/github-actions-shared-workflows/src/config/release-tag-snapshot@fix/sigpipe-tag-snapshot + uses: LerianStudio/github-actions-shared-workflows/src/config/release-tag-snapshot@v1.22.0 - name: Semantic Release uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 diff --git a/.github/workflows/typescript-build.yml b/.github/workflows/typescript-build.yml index 891b3451..91082e55 100644 --- a/.github/workflows/typescript-build.yml +++ b/.github/workflows/typescript-build.yml @@ -133,6 +133,10 @@ on: description: 'JSON mapping of component names to values.yaml keys' type: string default: '' + enable_cosign_sign: + description: 'Sign container images with cosign keyless (OIDC) signing after push. Requires id-token: write permission in the caller.' + type: boolean + default: true workflow_dispatch: inputs: @@ -144,6 +148,7 @@ on: permissions: contents: read packages: write + id-token: write jobs: prepare: @@ -255,6 +260,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Build and push Docker image + id: docker-build uses: LerianStudio/github-actions-shared-workflows/src/build/docker-build-ts@v1.18.0 with: enable-dockerhub: ${{ inputs.enable_dockerhub }} @@ -275,7 +281,63 @@ jobs: is-release: ${{ needs.prepare.outputs.is_release }} dry-run: ${{ inputs.dry_run }} - # GitOps artifacts for downstream gitops-update workflow + # ----------------- Cosign Image Signing ----------------- + - name: Normalize repository owner to lowercase + if: inputs.enable_cosign_sign && !inputs.dry_run + id: normalize + env: + REPO_OWNER: ${{ github.repository_owner }} + run: | + echo "owner_lower=$(echo "$REPO_OWNER" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Build cosign image references + if: inputs.enable_cosign_sign && !inputs.dry_run + id: cosign-refs + env: + DIGEST: ${{ steps.docker-build.outputs.image-digest }} + ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }} + ENABLE_GHCR: ${{ inputs.enable_ghcr }} + DOCKERHUB_ORG: ${{ inputs.dockerhub_org }} + INPUT_GHCR_ORG: ${{ inputs.ghcr_org }} + NORMALIZED_OWNER: ${{ steps.normalize.outputs.owner_lower }} + APP_NAME: ${{ matrix.app.name }} + run: | + if [ -z "$DIGEST" ]; then + echo "::warning::No image digest available — skipping cosign signing" + echo "refs=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + REFS="" + GHCR_ORG="$INPUT_GHCR_ORG" + if [ -z "$GHCR_ORG" ]; then + GHCR_ORG="$NORMALIZED_OWNER" + else + GHCR_ORG=$(echo "$GHCR_ORG" | tr '[:upper:]' '[:lower:]') + fi + + if [ "$ENABLE_DOCKERHUB" == "true" ]; then + REFS="${DOCKERHUB_ORG}/${APP_NAME}@${DIGEST}" + fi + + if [ "$ENABLE_GHCR" == "true" ]; then + [ -n "$REFS" ] && REFS="${REFS}"$'\n' + REFS="${REFS}ghcr.io/${GHCR_ORG}/${APP_NAME}@${DIGEST}" + fi + + { + echo "refs<> "$GITHUB_OUTPUT" + + - name: Sign container images with cosign + if: inputs.enable_cosign_sign && !inputs.dry_run && steps.cosign-refs.outputs.refs != '' + uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@feat/cosign-sign + with: + image-refs: ${{ steps.cosign-refs.outputs.refs }} + + # ----------------- GitOps Artifacts ----------------- - name: Create GitOps tag artifact if: inputs.enable_gitops_artifacts && !inputs.dry_run run: | diff --git a/.github/workflows/typescript-release.yml b/.github/workflows/typescript-release.yml index e3f2877d..92704d1e 100644 --- a/.github/workflows/typescript-release.yml +++ b/.github/workflows/typescript-release.yml @@ -114,14 +114,14 @@ jobs: gpg_fingerprint: ${{ steps.import_gpg.outputs.fingerprint }} steps: - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 id: app-token with: app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} @@ -132,7 +132,7 @@ jobs: git reset --hard origin/${{ github.ref_name }} - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7 id: import_gpg with: gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} @@ -144,7 +144,7 @@ jobs: git_commit_gpgsign: true - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ inputs.node_version }} @@ -168,7 +168,7 @@ jobs: @semantic-release/exec@7.1.0 - name: Semantic Release - uses: cycjimmy/semantic-release-action@v6 + uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 id: semantic with: ci: false diff --git a/AGENTS.md b/AGENTS.md index 935ebd36..f424f793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,3 +167,60 @@ Before modifying any existing file, follow the refactoring protocol in `.cursor/ - Never hardcode tokens, org names, or internal URLs — use `inputs` or `secrets` - Never print secrets via `echo`, `run` output, or step summaries - Vulnerability reports go through private channels only — see [`SECURITY.md`](SECURITY.md) + +### pull_request_target — NEVER checkout fork code + +- NEVER use `actions/checkout` with `ref: ${{ github.event.pull_request.head.ref }}` or `ref: ${{ github.event.pull_request.head.sha }}` in `pull_request_target` workflows +- If `pull_request_target` is needed (e.g., labeling, commenting), it MUST NOT run any code from the fork (no build, no test, no script execution from the PR branch) +- Prefer `pull_request` trigger over `pull_request_target` unless write permissions to the base repo are explicitly required +- NEVER use `secrets: inherit` in workflows triggered by `pull_request_target` — it exposes all repository secrets to fork code + +### Expression injection — sanitize ALL untrusted inputs + +Never use these directly in `run:` steps — always pass through an env var: + +```yaml +# ❌ These are injectable — NEVER interpolate directly in run: +${{ github.event.pull_request.title }} ${{ github.event.pull_request.body }} +${{ github.event.issue.title }} ${{ github.event.issue.body }} +${{ github.event.comment.body }} ${{ github.event.head_commit.message }} +${{ github.event.head_commit.author.name }} +${{ github.event.commits[*].message }} ${{ github.event.discussion.title }} +${{ github.event.discussion.body }} ${{ github.event.review.body }} +${{ github.head_ref }} +${{ github.event.pages[*].page_name }} + +# ✅ Safe — map to env var first +env: + PR_TITLE: ${{ github.event.pull_request.title }} +run: echo "$PR_TITLE" +``` + +### workflow_run — treat artifacts as untrusted + +- Workflows triggered by `workflow_run` MUST NOT trust artifacts from the triggering workflow blindly +- Validate/sanitize any data extracted from artifacts before use in shell commands or API calls +- Never extract and execute scripts from artifacts without verification + +### Permissions — principle of least privilege + +- ALWAYS declare explicit `permissions:` block at workflow level; default to `contents: read` +- Only escalate permissions per-job when needed +- NEVER use `permissions: write-all` or leave permissions undeclared (defaults to broad access in public repos) +- For comment/label-only workflows: `permissions: { pull-requests: write }` — nothing else + +### Secrets in fork contexts + +- NEVER pass secrets to steps that run fork code +- `pull_request` from a fork does NOT have access to secrets by default — do not circumvent this +- If a workflow needs secrets + fork code, split: `pull_request` (no secrets, runs fork code) + `workflow_run` (has secrets, trusted code only) + +### Script injection via labels/branches + +- Validate branch names and label names before using in shell commands +- Branch names can contain shell metacharacters — always quote variables and map through `env:` + +### Self-hosted runners + +- NEVER use self-hosted runners for `pull_request` or `pull_request_target` from public repos — a fork can execute arbitrary code on the runner +- Self-hosted runners are only safe for `push`, `workflow_dispatch`, `schedule`, and other non-fork triggers diff --git a/docs/build-workflow.md b/docs/build-workflow.md index b665d13a..3054f54a 100644 --- a/docs/build-workflow.md +++ b/docs/build-workflow.md @@ -107,6 +107,7 @@ jobs: | `build_context` | string | `.` | Docker build context | | `enable_gitops_artifacts` | boolean | `false` | Upload artifacts for gitops-update workflow | | `force_multiplatform` | boolean | `false` | Force multi-platform build (amd64+arm64) even for beta/rc tags | +| `enable_cosign_sign` | boolean | `true` | Sign images with cosign keyless (OIDC) signing. Requires `id-token: write` in caller | ## Secrets @@ -194,6 +195,41 @@ Automatically sends notifications on completion: ### notify - Sends Slack notification on completion +## Image Signing (cosign) + +Container images are signed by default using [Sigstore cosign](https://github.com/sigstore/cosign) with keyless (OIDC) signing. The GitHub Actions identity is used as proof of provenance — no private keys are needed. + +### Caller permissions + +Callers **must** grant `id-token: write` for signing to work: + +```yaml +permissions: + contents: read + packages: write + id-token: write # required for cosign keyless signing +``` + +### Disabling signing + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/build.yml@v1.0.0 + with: + enable_cosign_sign: false + secrets: inherit +``` + +### Verifying signatures + +```bash +cosign verify \ + --certificate-identity-regexp=".*" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + docker.io/lerianstudio/my-app@sha256:abc123... +``` + ## Best Practices 1. **Use semantic versioning tags**: `v1.0.0`, `v1.0.0-beta.1`, `v1.0.0-rc.1` diff --git a/docs/go-release-workflow.md b/docs/go-release-workflow.md index 8733b1b6..86412ddb 100644 --- a/docs/go-release-workflow.md +++ b/docs/go-release-workflow.md @@ -114,6 +114,7 @@ jobs: | `docker_platforms` | Docker platforms (comma-separated) | No | `linux/amd64,linux/arm64` | | `docker_tags` | Docker image tags configuration | No | Semver + latest | | `enable_notifications` | Enable release notifications | No | `false` | +| `enable_cosign_sign` | Sign Docker images with cosign keyless (OIDC) signing. Requires `id-token: write` in caller | No | `true` | ## Secrets @@ -172,6 +173,42 @@ jobs: run_tests_before_release: false ``` +## Image Signing (cosign) + +When Docker is enabled, container images are signed by default using [Sigstore cosign](https://github.com/sigstore/cosign) with keyless (OIDC) signing. + +### Caller permissions + +Callers **must** grant `id-token: write` for signing to work: + +```yaml +permissions: + contents: write + packages: write + id-token: write # required for cosign keyless signing +``` + +### Disabling signing + +```yaml +jobs: + release: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/go-release.yml@v1.0.0 + with: + enable_docker: true + enable_cosign_sign: false + secrets: inherit +``` + +### Verifying signatures + +```bash +cosign verify \ + --certificate-identity-regexp=".*" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + ghcr.io/myorg/my-app@sha256:abc123... +``` + ## Release Process 1. Create tag: `git tag v1.0.0 && git push --tags` diff --git a/docs/typescript-build.md b/docs/typescript-build.md index 87d77ebc..6623214b 100644 --- a/docs/typescript-build.md +++ b/docs/typescript-build.md @@ -17,6 +17,7 @@ The build logic is encapsulated in the [`docker-build-ts`](../src/build/docker-b | `npmrc` secret | Not included | Always injected automatically | | `build_secrets` behavior | Replaces all secrets | Additive (extra secrets on top of npmrc) | | `dry_run` mode | Not available | Available | +| Cosign signing | Enabled by default | Enabled by default (skipped in dry-run) | | `workflow_dispatch` | Not available | Available for manual testing | | Dockerfile per component | Uses `dockerfile_name` only | Resolves `matrix.app.dockerfile` with fallback | @@ -147,6 +148,7 @@ jobs: | `helm_dispatch_on_rc` | boolean | `false` | Enable Helm dispatch for rc tags | | `helm_dispatch_on_beta` | boolean | `false` | Enable Helm dispatch for beta tags | | `helm_values_key_mappings` | string | `''` | Component names to values.yaml keys mapping | +| `enable_cosign_sign` | boolean | `true` | Sign images with cosign keyless (OIDC) signing. Requires `id-token: write` in caller | ## Secrets @@ -198,6 +200,41 @@ Dockerfiles must mount the `npmrc` secret for installing private packages: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install ``` +## Image Signing (cosign) + +Container images are signed by default using [Sigstore cosign](https://github.com/sigstore/cosign) with keyless (OIDC) signing. Signing is skipped during dry-run mode (no digest available). + +### Caller permissions + +Callers **must** grant `id-token: write` for signing to work: + +```yaml +permissions: + contents: read + packages: write + id-token: write # required for cosign keyless signing +``` + +### Disabling signing + +```yaml +jobs: + build: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/typescript-build.yml@v1.0.0 + with: + enable_cosign_sign: false + secrets: inherit +``` + +### Verifying signatures + +```bash +cosign verify \ + --certificate-identity-regexp=".*" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + ghcr.io/lerianstudio/my-app@sha256:abc123... +``` + ## Related Workflows - [`build.yml`](build.md) — Generic Docker build workflow (Go-oriented defaults) diff --git a/src/security/cosign-sign/README.md b/src/security/cosign-sign/README.md new file mode 100644 index 00000000..0151761d --- /dev/null +++ b/src/security/cosign-sign/README.md @@ -0,0 +1,80 @@ + + + + + +
Lerian

cosign-sign

+ +Composite action that signs container images using [Sigstore cosign](https://github.com/sigstore/cosign) with keyless (OIDC) signing. Uses the GitHub Actions OIDC identity provider — no private keys to manage. Signatures are stored in the registry alongside the image. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `image-refs` | Newline-separated fully qualified image references to sign (e.g., `docker.io/org/app@sha256:abc...`) | Yes | — | +| `cosign-version` | Cosign version to install | No | `v2.5.0` | +| `dry-run` | Log what would be signed without actually signing | No | `false` | + +## Outputs + +| Output | Description | +|---|---| +| `signed-refs` | Newline-separated list of successfully signed image references | + +## Usage + +### As a composite step (after Docker build and push) + +```yaml +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + packages: write + id-token: write # required for keyless signing + steps: + - uses: actions/checkout@v6 + + - name: Build and push Docker image + id: build-push + uses: docker/build-push-action@v7 + with: + push: true + tags: myorg/myapp:1.0.0 + + - name: Sign container image + uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@v1.x.x + with: + image-refs: myorg/myapp@${{ steps.build-push.outputs.digest }} +``` + +### Signing multiple registries + +```yaml + - name: Sign container images + uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@v1.x.x + with: + image-refs: | + docker.io/myorg/myapp@${{ steps.build-push.outputs.digest }} + ghcr.io/myorg/myapp@${{ steps.build-push.outputs.digest }} +``` + +### Verifying signatures + +```bash +cosign verify \ + --certificate-identity-regexp=".*" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + docker.io/myorg/myapp@sha256:abc123... +``` + +## Permissions required + +```yaml +permissions: + id-token: write # required — OIDC token for keyless signing + packages: write # if pushing signatures to GHCR +``` + +> **Note:** The calling job must have `id-token: write` permission for keyless signing to work. Without it, cosign cannot obtain an OIDC token and the signing step will fail. diff --git a/src/security/cosign-sign/action.yml b/src/security/cosign-sign/action.yml new file mode 100644 index 00000000..16403f0a --- /dev/null +++ b/src/security/cosign-sign/action.yml @@ -0,0 +1,108 @@ +name: Cosign Keyless Image Signing +description: Sign container images using Sigstore cosign with keyless (OIDC) signing via GitHub Actions identity. + +inputs: + image-refs: + description: Newline-separated fully qualified image references to sign (e.g., docker.io/org/app@sha256:abc...) + required: true + cosign-version: + description: Cosign version to install + required: false + default: "v2.5.0" + dry-run: + description: Log what would be signed without actually signing + required: false + default: "false" + +outputs: + signed-refs: + description: Newline-separated list of successfully signed image references + value: ${{ steps.sign.outputs.signed_refs }} + +runs: + using: composite + steps: + # ----------------- Setup ----------------- + - name: Install cosign + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 + with: + cosign-release: ${{ inputs.cosign-version }} + + # ----------------- Validation ----------------- + - name: Validate image references + id: validate + shell: bash + env: + IMAGE_REFS: ${{ inputs.image-refs }} + run: | + if [ -z "$IMAGE_REFS" ]; then + echo "::error::No image references provided" + exit 1 + fi + + VALID_COUNT=0 + while IFS= read -r ref; do + ref=$(echo "$ref" | xargs) + [ -z "$ref" ] && continue + if [[ "$ref" != *"@sha256:"* ]]; then + echo "::error::Invalid image reference (missing @sha256: digest): $ref" + exit 1 + fi + VALID_COUNT=$((VALID_COUNT + 1)) + done <<< "$IMAGE_REFS" + + if [ "$VALID_COUNT" -eq 0 ]; then + echo "::error::No valid image references found" + exit 1 + fi + + echo "count=$VALID_COUNT" >> "$GITHUB_OUTPUT" + + # ----------------- Dry Run ----------------- + - name: Dry run summary + if: ${{ inputs.dry-run == 'true' }} + shell: bash + env: + IMAGE_REFS: ${{ inputs.image-refs }} + COSIGN_VERSION: ${{ inputs.cosign-version }} + VALID_COUNT: ${{ steps.validate.outputs.count }} + run: | + echo "::notice::DRY RUN — no images will be signed" + echo " cosign version : $COSIGN_VERSION" + echo " OIDC issuer : https://token.actions.githubusercontent.com" + echo " images ($VALID_COUNT):" + while IFS= read -r ref; do + ref=$(echo "$ref" | xargs) + [ -z "$ref" ] && continue + echo " - $ref" + done <<< "$IMAGE_REFS" + + # ----------------- Sign ----------------- + - name: Sign container images + if: ${{ inputs.dry-run != 'true' }} + id: sign + shell: bash + env: + IMAGE_REFS: ${{ inputs.image-refs }} + VALID_COUNT: ${{ steps.validate.outputs.count }} + run: | + SIGNED="" + while IFS= read -r ref; do + ref=$(echo "$ref" | xargs) + [ -z "$ref" ] && continue + echo "Signing: $ref" + cosign sign --yes "$ref" + if [ -n "$SIGNED" ]; then + SIGNED="${SIGNED}"$'\n'"${ref}" + else + SIGNED="$ref" + fi + done <<< "$IMAGE_REFS" + + { + echo "signed_refs<> "$GITHUB_OUTPUT" + + echo "::notice::Successfully signed $VALID_COUNT image(s)" diff --git a/src/security/pr-security-reporter/action.yml b/src/security/pr-security-reporter/action.yml index 4ee3ecc7..f9da4ec6 100644 --- a/src/security/pr-security-reporter/action.yml +++ b/src/security/pr-security-reporter/action.yml @@ -38,7 +38,7 @@ runs: steps: - name: Post security report to PR id: report - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: APP_NAME: ${{ inputs.app-name }} ENABLE_DOCKER_SCAN: ${{ inputs.enable-docker-scan }} @@ -335,11 +335,16 @@ runs: - name: Gate - Fail on Security Findings if: inputs.fail-on-findings == 'true' shell: bash + env: + HAS_ERRORS: ${{ steps.parse-outputs.outputs.has_errors }} + HAS_FINDINGS: ${{ steps.parse-outputs.outputs.has_findings }} run: | - if [ "${{ steps.parse-outputs.outputs.has_errors }}" = "true" ]; then - echo "::warning::Some scan artifacts were missing or could not be parsed." + if [ "$HAS_ERRORS" = "true" ]; then + echo "::error::Some scan artifacts were missing or could not be parsed. Failing to prevent bypass." fi - if [ "${{ steps.parse-outputs.outputs.has_findings }}" = "true" ]; then + if [ "$HAS_FINDINGS" = "true" ]; then echo "::error::Security vulnerabilities found. Check the PR comment for details." + fi + if [ "$HAS_ERRORS" = "true" ] || [ "$HAS_FINDINGS" = "true" ]; then exit 1 fi