diff --git a/.github/workflows/changelog-submit.yml b/.github/workflows/changelog-submit.yml index 0dc03b2..9e9a4b4 100644 --- a/.github/workflows/changelog-submit.yml +++ b/.github/workflows/changelog-submit.yml @@ -33,38 +33,6 @@ jobs: should-submit: ${{ steps.evaluate.outputs.should-submit }} is-org-member: ${{ steps.check-org-membership.outputs.is-member }} steps: - - name: Resolve PR author - id: pr-author - if: github.event.workflow_run.head_repository.full_name != github.repository - uses: actions/github-script@v9 - with: - # language=js - script: | - const run = context.payload.workflow_run; - const { owner, repo } = context.repo; - - let prNumber; - if (run.pull_requests?.length > 0) { - prNumber = run.pull_requests[0].number; - } else { - const headLabel = `${run.head_repository.owner.login}:${run.head_branch}`; - const { data: prs } = await github.rest.pulls.list({ - owner, repo, state: 'open', head: headLabel - }); - const match = prs.find(pr => pr.head.sha === run.head_sha); - if (match) prNumber = match.number; - } - - if (!prNumber) { - core.setFailed('Could not resolve PR number for fork — cannot verify org membership. Failing closed.'); - return; - } - - const { data: pr } = await github.rest.pulls.get({ - owner, repo, pull_number: prNumber - }); - core.setOutput('login', pr.user.login); - - name: Fetch ephemeral GitHub token if: github.event.workflow_run.head_repository.full_name != github.repository id: fetch-ephemeral-token @@ -78,7 +46,6 @@ jobs: if: github.event.workflow_run.head_repository.full_name != github.repository uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - username: ${{ steps.pr-author.outputs.login }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index da9f113..b60c2c1 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -347,7 +347,7 @@ jobs: persist-credentials: false - name: Run Vale Linter id: lint - uses: elastic/vale-rules/lint@main + uses: elastic/vale-rules/lint@f1d7270dfe289989a3acbdfcaab8c97a4a32d7d1 # v1.4.0 with: files: ${{ needs.check.outputs.all_changed_files }} vale-paths: ${{ inputs.vale-paths }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index f8b9b47..c95ad3b 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -153,6 +153,7 @@ jobs: core.setOutput('base-ref', pr.base.ref); core.setOutput('pr-author', pr.user.login); + // --- Changed-files check (docs-relevant files only) --- const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber @@ -236,7 +237,6 @@ jobs: if: steps.context.outputs.is-fork == 'true' && steps.context.outputs.event == 'pull_request' uses: elastic/docs-actions/github/is-elastic-org-member@v1 with: - username: ${{ steps.context.outputs.pr-author }} token: ${{ steps.fetch-ephemeral-token.outputs.token }} - name: Evaluate @@ -352,6 +352,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read outputs: build_outcome: ${{ steps.docs-build.outcome == 'success' && 'success' || '' }} skip: ${{ steps.docs-build.outputs.skip }} @@ -414,11 +415,46 @@ jobs: echo "PATH_PREFIX=${path_prefix}" >> "$GITHUB_ENV" echo "result=${path_prefix}" >> "$GITHUB_OUTPUT" + # Resolve the mutable :edge tag to an immutable RepoDigest, then + # verify the SLSA build-provenance attestation that + # `elastic/docs-builder`'s prerelease.yml mints for every push + # via `actions/attest-build-provenance` (see + # elastic/docs-eng-team#518). This proves the image was built by + # a known workflow on a known commit and has not been tampered + # with on the registry side. + - name: Pull and pin docs-builder image + id: docker-image + env: + GH_TOKEN: ${{ github.token }} + # language=bash + run: | + set -euo pipefail + IMAGE="ghcr.io/elastic/docs-builder:edge" + docker pull "$IMAGE" + DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE") + if [ -z "$DIGEST" ]; then + echo "::error::Failed to resolve RepoDigest for ${IMAGE}" + exit 1 + fi + + # Fail closed if the attestation is missing, malformed, or + # signed by an unexpected workflow. `-R` constrains the + # attestation issuer to the docs-builder repo so an attacker + # cannot publish a self-signed attestation under their own + # repo and have it accepted here. + gh attestation verify "oci://${DIGEST}" -R elastic/docs-builder + + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "::notice title=docs-builder image digest::${DIGEST}" + # Run docs-builder in Docker isolation. Only explicitly listed env vars are # passed to the container — ACTIONS_RUNTIME_TOKEN, ACTIONS_CACHE_URL, and # OIDC env vars are excluded to prevent cache poisoning and credential # theft if the build tool is compromised via malicious content. # + # The image is referenced by digest (resolved above) so the run is + # immutable for this workflow execution. + # # Future: add --network none once docs-builder has an init command to # preload the link index before the build. - name: Build documentation @@ -442,7 +478,7 @@ jobs: -e GITHUB_REF="refs/heads/${HEAD_BRANCH}" \ -e INPUT_PREFIX="${PATH_PREFIX}" \ -e INPUT_STRICT="${STRICT_FLAG}" \ - ghcr.io/elastic/docs-builder:edge || EXIT_CODE=$? + "${IMAGE_DIGEST}" || EXIT_CODE=$? if [ -s "$CONTAINER_OUTPUT" ]; then cat "$CONTAINER_OUTPUT" >> "$GITHUB_OUTPUT" @@ -451,6 +487,7 @@ jobs: exit $EXIT_CODE env: STRICT_FLAG: ${{ fromJSON(inputs.strict != '' && inputs.strict || 'true') }} + IMAGE_DIGEST: ${{ steps.docker-image.outputs.digest }} - name: Upload links artifact id: upload-links @@ -776,7 +813,7 @@ jobs: - name: Post Vale Results if: steps.vale-artifact.outputs.found == 'true' - uses: elastic/vale-rules/report@main + uses: elastic/vale-rules/report@f1d7270dfe289989a3acbdfcaab8c97a4a32d7d1 # v1.4.0 # Uploads links.json to the shared link index S3 bucket. # Runs concurrently with deploy-preview — both depend only on build. diff --git a/changelog/submit/apply/scripts/comment-helper.js b/changelog/submit/apply/scripts/comment-helper.js index 05aa77f..a9b367f 100644 --- a/changelog/submit/apply/scripts/comment-helper.js +++ b/changelog/submit/apply/scripts/comment-helper.js @@ -1,6 +1,51 @@ +// Trust boundary: +// All env-var inputs that flow into the comment body — CHANGELOG_FILE, +// CHANGELOG_DIR, HEAD_REF, LABEL_TABLE, PRODUCT_LABEL_TABLE, SKIP_LABELS, +// CONFIG_FILE, and the staged YAML content — originate from PR metadata or +// repo configuration that an attacker can influence. Use the helpers below +// when interpolating any of those values into a Markdown comment: +// - escapeMarkdown() for inline text. Escapes Markdown punctuation *and* +// HTML-significant characters (<, >, &) so a hostile value cannot +// introduce raw HTML. +// - wrapCodeFence() for multi-line content embedded as a code block. +// Picks a backtick fence longer than any sequence in the content so a +// stray ``` cannot break out of the block. const TITLE = '### 📋 Changelog'; -const escapeMarkdown = (s) => s.replace(/([[\]()\\`*_{}#+\-.!|])/g, '\\$1'); +// Escapes Markdown punctuation and HTML-significant characters. Sticking +// to ASCII printable range; the OutputSanitizer in docs-builder already +// strips C0/DEL controls before these values reach the runner. +const escapeMarkdown = (s) => + String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/([[\]()\\`*_{}#+\-.!|])/g, '\\$1'); + +// Returns content wrapped in a backtick fence whose length is one greater +// than the longest run of backticks already present in `content`. Prevents +// the embedded content from closing the outer fence prematurely. +const wrapCodeFence = (content, language = '') => { + const matches = String(content ?? '').match(/`+/g) ?? []; + const longest = matches.reduce((max, run) => Math.max(max, run.length), 0); + const fence = '`'.repeat(Math.max(3, longest + 1)); + return `${fence}${language}\n${content}\n${fence}`; +}; + +// Returns the text wrapped as inline code using a backtick run longer than +// any run inside the text. Prefer this over `escapeMarkdown(s)` wrapped in +// single backticks when `s` may itself contain backticks (e.g., user- +// supplied label or path values). +const wrapInlineCode = (s) => { + const text = String(s ?? ''); + const matches = text.match(/`+/g) ?? []; + const longest = matches.reduce((max, run) => Math.max(max, run.length), 0); + const tick = '`'.repeat(longest + 1); + // CommonMark: pad with a single space if the content starts or ends with + // a backtick, so the boundary backticks aren't absorbed into the run. + const padded = (text.startsWith('`') || text.endsWith('`')) ? ` ${text} ` : text; + return `${tick}${padded}${tick}`; +}; async function upsertComment({ github, context, prNumber, body }) { const { owner, repo } = context.repo; @@ -17,4 +62,4 @@ async function upsertComment({ github, context, prNumber, body }) { } } -module.exports = { TITLE, upsertComment, escapeMarkdown }; +module.exports = { TITLE, upsertComment, escapeMarkdown, wrapCodeFence, wrapInlineCode }; diff --git a/changelog/submit/apply/scripts/post-comment-only.js b/changelog/submit/apply/scripts/post-comment-only.js index 0f38fb8..3486178 100644 --- a/changelog/submit/apply/scripts/post-comment-only.js +++ b/changelog/submit/apply/scripts/post-comment-only.js @@ -1,5 +1,5 @@ const fs = require('fs'); -const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper'); +const { TITLE, upsertComment, escapeMarkdown, wrapCodeFence } = require('./comment-helper'); module.exports = async ({ github, context, core }) => { const prNumber = parseInt(process.env.PR_NUMBER, 10); @@ -16,9 +16,10 @@ module.exports = async ({ github, context, core }) => { bodyParts.push( `Generated changelog entry for \`${escapeMarkdown(changelogDir + '/' + files[0])}\`:`, '', - '```yaml', - content, - '```', + // wrapCodeFence picks a backtick run longer than any in `content`, so + // attacker-supplied YAML cannot close the fence early and inject + // arbitrary Markdown after the block. + wrapCodeFence(content, 'yaml'), '', 'This comment is informational — editing it does not change what gets uploaded. On merge, the entry is regenerated from the live PR record (title, labels) and uploaded to S3. To change the preview, edit the PR title or labels and let the changelog workflow re-run.', ); diff --git a/changelog/submit/apply/scripts/post-failure-comment.js b/changelog/submit/apply/scripts/post-failure-comment.js index f77fb32..7eff5b8 100644 --- a/changelog/submit/apply/scripts/post-failure-comment.js +++ b/changelog/submit/apply/scripts/post-failure-comment.js @@ -1,4 +1,4 @@ -const { TITLE, upsertComment } = require('./comment-helper'); +const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper'); module.exports = async ({ github, context, core }) => { const prNumber = parseInt(process.env.PR_NUMBER, 10); @@ -7,6 +7,8 @@ module.exports = async ({ github, context, core }) => { const productLabelRows = process.env.PRODUCT_LABEL_TABLE || ''; const skipLabels = process.env.SKIP_LABELS || ''; + const configFileCode = wrapInlineCode(configFile); + let labelSection; if (labelRows.trim()) { labelSection = [ @@ -16,7 +18,7 @@ module.exports = async ({ github, context, core }) => { labelRows, ].join('\n'); } else { - labelSection = `\nAdd a type label that matches your \`pivot.types\` configuration in \`${configFile}\`.`; + labelSection = `\nAdd a type label that matches your ${wrapInlineCode('pivot.types')} configuration in ${configFileCode}.`; } let productSection = ''; @@ -31,10 +33,10 @@ module.exports = async ({ github, context, core }) => { let skipSection; if (skipLabels.trim()) { - const formatted = skipLabels.split(',').map(l => `\`${l.trim()}\``).join(', '); + const formatted = skipLabels.split(',').map(l => wrapInlineCode(l.trim())).join(', '); skipSection = `\n⏭️ To skip changelog generation, add one of these labels: ${formatted}`; } else { - skipSection = `\n⏭️ No skip labels are configured. To allow skipping changelog generation, add a label to \`rules.create.exclude\` in \`${configFile}\`.`; + skipSection = `\n⏭️ No skip labels are configured. To allow skipping changelog generation, add a label to ${wrapInlineCode('rules.create.exclude')} in ${configFileCode}.`; } const body = [ @@ -45,7 +47,7 @@ module.exports = async ({ github, context, core }) => { productSection, skipSection, '', - `📄 See \`${configFile}\` for the full changelog configuration.`, + `📄 See ${configFileCode} for the full changelog configuration.`, ].join('\n'); await upsertComment({ github, context, prNumber, body }); diff --git a/changelog/submit/apply/scripts/post-success-comment.js b/changelog/submit/apply/scripts/post-success-comment.js index 232742a..07a9b35 100644 --- a/changelog/submit/apply/scripts/post-success-comment.js +++ b/changelog/submit/apply/scripts/post-success-comment.js @@ -1,5 +1,10 @@ -const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper'); +const { TITLE, upsertComment, wrapInlineCode } = require('./comment-helper'); +// changelogFile / branch are validated upstream by ref-name regex +// (`^[a-zA-Z0-9._/+-]+$`) plus OutputSanitizer in docs-builder, so they +// are constrained to a small alphabet. wrapInlineCode is still used for +// the visible filename so a stray backtick (or future loosening of the +// upstream regex) cannot break out of the inline code span. module.exports = async ({ github, context, core }) => { const prNumber = parseInt(process.env.PR_NUMBER, 10); const branch = process.env.HEAD_REF; @@ -13,7 +18,7 @@ module.exports = async ({ github, context, core }) => { const body = [ TITLE, '', - `📝 Changelog entry committed: [\`${escapeMarkdown(changelogFile)}\`](${viewUrl})`, + `📝 Changelog entry committed: [${wrapInlineCode(changelogFile)}](${viewUrl})`, '', `✏️ [Edit this changelog](${editUrl})`, ].join('\n'); diff --git a/changelog/submit/evaluate/action.yml b/changelog/submit/evaluate/action.yml index 5b427a1..7f7ede2 100644 --- a/changelog/submit/evaluate/action.yml +++ b/changelog/submit/evaluate/action.yml @@ -162,7 +162,7 @@ runs: REPO_NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ steps.pr.outputs.number }} PR_TITLE: ${{ steps.pr-data.outputs.title }} - PR_BODY: ${{ steps.pr-data.outputs.body }} + PR_BODY_FILE: ${{ steps.pr-data.outputs.body-file }} PR_LABELS: ${{ steps.pr-data.outputs.labels }} HEAD_REF: ${{ steps.pr-data.outputs.head-ref }} HEAD_SHA: ${{ steps.pr-data.outputs.head-sha }} diff --git a/changelog/submit/evaluate/scripts/fetch-pr-data.js b/changelog/submit/evaluate/scripts/fetch-pr-data.js index f5dab1f..973e2bc 100644 --- a/changelog/submit/evaluate/scripts/fetch-pr-data.js +++ b/changelog/submit/evaluate/scripts/fetch-pr-data.js @@ -1,3 +1,15 @@ +const fs = require('fs'); +const path = require('path'); + +const TITLE_MAX_LEN = 200; +const BODY_FILE_MAX_BYTES = 64 * 1024; + +const sanitizeInline = (value, maxLen) => + (value || '') + .replace(/\u0000/g, '') + .replace(/\r/g, '') + .slice(0, maxLen); + module.exports = async ({ github, context, core }) => { const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -8,9 +20,38 @@ module.exports = async ({ github, context, core }) => { core.info(`PR #${pr.number} is ${pr.state} — skipping`); return; } - core.setOutput('title', pr.title); - core.setOutput('body', pr.body || ''); - core.setOutput('labels', pr.labels.map(l => l.name).join(',')); + + const labelNames = pr.labels.map(l => l.name); + const offendingLabel = labelNames.find(name => name.includes(',')); + if (offendingLabel) { + core.setFailed( + `Label name contains ',' which would corrupt comma-joined parsing: ${JSON.stringify(offendingLabel)}` + ); + return; + } + + // Stage the body in a file rather than passing it inline. + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + core.setFailed('RUNNER_TEMP is not set; cannot stage PR body file'); + return; + } + const bodyFile = path.join(runnerTemp, 'changelog-pr-body.md'); + const rawBody = (pr.body || '').replace(/\u0000/g, ''); + const bodyBytes = Buffer.from(rawBody, 'utf8'); + const cappedBody = bodyBytes.length > BODY_FILE_MAX_BYTES + ? bodyBytes.subarray(0, BODY_FILE_MAX_BYTES).toString('utf8') + : rawBody; + if (bodyBytes.length > BODY_FILE_MAX_BYTES) { + core.warning( + `PR body exceeds ${BODY_FILE_MAX_BYTES} bytes (${bodyBytes.length}); truncating.` + ); + } + fs.writeFileSync(bodyFile, cappedBody, { encoding: 'utf8', mode: 0o600 }); + + core.setOutput('title', sanitizeInline(pr.title, TITLE_MAX_LEN)); + core.setOutput('body-file', bodyFile); + core.setOutput('labels', labelNames.join(',')); core.setOutput('is-fork', String(pr.head.repo?.full_name !== pr.base.repo?.full_name)); core.setOutput('head-repo', pr.head.repo?.full_name || ''); core.setOutput('maintainer-can-modify', String(pr.maintainer_can_modify ?? false)); diff --git a/changelog/validate/action.yml b/changelog/validate/action.yml index 6df464f..efb1b45 100644 --- a/changelog/validate/action.yml +++ b/changelog/validate/action.yml @@ -32,6 +32,17 @@ runs: version: edge github-token: ${{ inputs.github-token }} + - name: Stage PR body + id: stage-body + shell: bash + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + BODY_FILE="${RUNNER_TEMP}/changelog-pr-body.md" + printf '%s' "${PR_BODY:-}" > "$BODY_FILE" + chmod 600 "$BODY_FILE" + echo "path=${BODY_FILE}" >> "$GITHUB_OUTPUT" + - name: Evaluate PR id: evaluate shell: bash @@ -42,7 +53,7 @@ runs: REPO_NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} - PR_BODY: ${{ github.event.pull_request.body }} + PR_BODY_FILE: ${{ steps.stage-body.outputs.path }} PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} diff --git a/docs-builder/setup/action.yml b/docs-builder/setup/action.yml index 134c8b5..d698a18 100644 --- a/docs-builder/setup/action.yml +++ b/docs-builder/setup/action.yml @@ -22,7 +22,31 @@ runs: mkdir -p "${INSTALL_DIR}" if [[ "${DOCS_BUILDER_VERSION}" == "edge" ]]; then - docker cp $(docker create --name tc ghcr.io/elastic/docs-builder:edge):/app/docs-builder "${INSTALL_DIR}/docs-builder" && docker rm tc + # Resolve :edge to a RepoDigest, then verify the SLSA + # build-provenance attestation minted by docs-builder's + # prerelease.yml (elastic/docs-eng-team#518). This matches + # the non-edge path's `gh attestation verify` step on the + # release zip below — :edge is no longer the unverified + # cousin. Fail closed if the attestation is missing or + # signed by an unexpected workflow. + EDGE_IMAGE="ghcr.io/elastic/docs-builder:edge" + docker pull "${EDGE_IMAGE}" + EDGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${EDGE_IMAGE}") + if [ -z "${EDGE_DIGEST}" ]; then + echo "::error::Failed to resolve RepoDigest for ${EDGE_IMAGE}" + exit 1 + fi + + # `-R` constrains the attestation issuer to the + # docs-builder repo so an attacker cannot publish a + # self-signed attestation under their own repo and have it + # accepted here. + gh attestation verify "oci://${EDGE_DIGEST}" -R elastic/docs-builder + + echo "::notice title=docs-builder image digest::${EDGE_DIGEST}" + CONTAINER_ID=$(docker create "${EDGE_DIGEST}") + trap "docker rm -f ${CONTAINER_ID} >/dev/null 2>&1 || true" EXIT + docker cp "${CONTAINER_ID}:/app/docs-builder" "${INSTALL_DIR}/docs-builder" else if [[ "${DOCS_BUILDER_VERSION}" == "latest" ]]; then DOCS_BUILDER_VERSION="" # empty string to get the latest version diff --git a/github/is-elastic-org-member/README.md b/github/is-elastic-org-member/README.md index 55e2251..f486017 100644 --- a/github/is-elastic-org-member/README.md +++ b/github/is-elastic-org-member/README.md @@ -1,25 +1,70 @@ # github/is-elastic-org-member -Checks whether a GitHub user is a member of the elastic org using a GitHub token with read:org scope. +Verifies that one or more GitHub users are members of the elastic org using a GitHub token with `read:org` scope. When `collect-from-workflow-run` is `true`, the action also auto-collects every login involved in the upstream `workflow_run` event (PR opener + `actor` + `triggering_actor` + head-commit author/committer) so callers don't duplicate that logic. **All resolved users must be confirmed members for `is-member` to be `true`** — fail-closed semantics for trust gates. + +See [elastic/docs-eng-team#511](https://github.com/elastic/docs-eng-team/issues/511) for the security review that motivates the multi-login check. ## Inputs -| Name | Description | Required | Default | -|------------|---------------------------------------|----------|---------| -| `username` | GitHub username to check | `true` | ` ` | -| `token` | GitHub token with the necessary scope | `true` | ` ` | +| Name | Description | Required | Default | +|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------| +| `username` | Single GitHub username to check. Backward-compat input for callers that only need to verify one user. Merged with `usernames` and any auto-collected logins. | `false` | `''` | +| `usernames` | Newline- or comma-separated list of additional GitHub usernames. Merged with `username` and any auto-collected logins. | `false` | `''` | +| `collect-from-workflow-run` | When `'true'` (default) and the calling workflow was triggered by `workflow_run`, automatically include the PR opener, the upstream run's `actor` and `triggering_actor`, and the head commit's author and committer in the membership check. Set to `'false'` to validate only the explicit `username` / `usernames` inputs. | `false` | `'true'` | +| `token` | GitHub token with `read:org` scope (for membership checks) and at minimum read access to public commits (for fork head-commit lookups). Typically an ephemeral Vault-issued token. | `true` | ` ` | ## Outputs -| Name | Description | -|-------------|-------------------------------------------------------------------------| -| `is-member` | 'true' if the user is a confirmed elastic org member, 'false' otherwise | +| Name | Description | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `is-member` | `'true'` if every resolved user is a confirmed elastic org member, `'false'` otherwise (including when no usernames were resolved). | +| `non-members` | Newline-separated list of usernames that failed the membership check. Empty when `is-member` is `true`. | +| `checked-usernames` | Newline-separated list of usernames that were validated and checked (after deduplication and bot/invalid filtering). Useful for diagnostic logging. | +## Behavior + +- The action is implemented in JavaScript via `actions/github-script@v9`. All + string handling, splitting, deduplication, and validation happens in JS to + avoid shell-quoting and `grep`/`sed` portability concerns. +- Inputs are split on newline **and** comma, trimmed, and case-insensitively + deduped. +- The GitHub `web-flow` ghost account (used as committer for web-UI edits and + squash merges) and `noreply` are filtered out before validation. +- Each remaining value is validated against the GitHub username grammar + (`^[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}$`) before being passed to the API. + Anything that doesn't match (including bot logins ending in `[bot]`) is + silently dropped. +- If no valid usernames remain after filtering, the action fails closed + (`is-member=false`). +- Each membership check uses `octokit.orgs.checkMembershipForUser`. Any + non-200 response (including the documented `302 Found` for + not-publicly-visible memberships and `404 Not Found` for non-members) is + treated as "not a confirmed member" and added to `non-members`. + ## Usage + +### Workflow_run trust gate (recommended) + +Pass only the token and let the action collect every login that touched the +upstream run: + +```yaml +- uses: elastic/docs-actions/github/is-elastic-org-member@v1 + with: + token: ${{ steps.fetch-ephemeral-token.outputs.token }} +``` + +This is equivalent to listing the PR opener, `workflow_run.actor.login`, +`workflow_run.triggering_actor.login`, and the head commit's `author.login` +and `committer.login` — and requiring **all** of them to be elastic +members. + +### Single user (legacy) + ```yaml on: push @@ -27,3 +72,15 @@ steps: - uses: elastic/docs-actions/github/is-elastic-org-member@v1 ``` + +### Explicit username list (auto-collection disabled) + +```yaml +- uses: elastic/docs-actions/github/is-elastic-org-member@v1 + with: + collect-from-workflow-run: 'false' + usernames: | + ${{ inputs.user-a }} + ${{ inputs.user-b }} + token: ${{ steps.fetch-ephemeral-token.outputs.token }} +``` diff --git a/github/is-elastic-org-member/action.yml b/github/is-elastic-org-member/action.yml index baf3a04..ddb5b7e 100644 --- a/github/is-elastic-org-member/action.yml +++ b/github/is-elastic-org-member/action.yml @@ -1,46 +1,210 @@ name: github/is-elastic-org-member description: > - Checks whether a GitHub user is a member of the elastic org using a - GitHub token with read:org scope. + Verifies that one or more GitHub users are members of the elastic org + using a GitHub token with read:org scope. When `collect-from-workflow-run` + is true, also auto-collects every login involved in the upstream + workflow_run (PR opener + actor + triggering_actor + head-commit + author/committer) so callers don't duplicate that logic. All resolved + users must be confirmed members for `is-member` to be `true` (fail-closed + semantics for trust gates — see elastic/docs-eng-team#511). inputs: username: - description: GitHub username to check - required: true + description: > + Single GitHub username to check. Backward-compat input for callers + that only need to verify one user. Merged with `usernames` and any + auto-collected logins. + required: false + default: '' + usernames: + description: > + Newline- or comma-separated list of additional GitHub usernames. + Merged with `username` and any auto-collected logins. + required: false + default: '' + collect-from-workflow-run: + description: > + When 'true' (default) and the calling workflow was triggered by + `workflow_run`, automatically include the PR opener, the upstream + run's `actor` and `triggering_actor`, and the head commit's author + and committer in the membership check. Set to 'false' to validate + only the explicit `username` / `usernames` inputs. + required: false + default: 'true' token: - description: GitHub token with the necessary scope + description: > + GitHub token with `read:org` scope (for membership checks) and at + minimum read access to public commits (for fork head-commit + lookups). Typically an ephemeral Vault-issued token. required: true outputs: is-member: - description: "'true' if the user is a confirmed elastic org member, 'false' otherwise" + description: > + "'true' if every resolved user is a confirmed elastic org member, + 'false' otherwise (including when no usernames were resolved)." value: ${{ steps.check.outputs.is-member }} + non-members: + description: > + Newline-separated list of usernames that failed the membership + check. Empty when `is-member` is `true`. Useful for logging which + account(s) caused the gate to fail. + value: ${{ steps.check.outputs.non-members }} + checked-usernames: + description: > + Newline-separated list of usernames that were validated and + checked (after deduplication and filtering of bots/invalid logins). + Useful for diagnostic logging. + value: ${{ steps.check.outputs.checked-usernames }} runs: using: composite steps: - - name: Check org membership + - name: Check elastic org membership id: check - shell: bash + uses: actions/github-script@v9 env: - GH_TOKEN: ${{ inputs.token }} - USERNAME: ${{ inputs.username }} - run: | - if [[ -z "$GH_TOKEN" || -z "$USERNAME" ]]; then - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "::warning::Token or username not available — cannot verify org membership" - exit 0 - fi - - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "Authorization: token $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/orgs/elastic/members/${USERNAME}") - - if [[ "$HTTP_CODE" == "204" ]]; then - echo "is-member=true" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is a member of the elastic org" - else - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "::notice::${USERNAME} is not a confirmed member of the elastic org (HTTP ${HTTP_CODE})" - fi + ORG_MEMBER_TOKEN: ${{ inputs.token }} + EXPLICIT_USERNAME: ${{ inputs.username }} + EXPLICIT_USERNAMES: ${{ inputs.usernames }} + COLLECT_FROM_WORKFLOW_RUN: ${{ inputs.collect-from-workflow-run }} + with: + # Use a separate Octokit instance so the org-membership token + # (read:org) is not conflated with the default GITHUB_TOKEN. + github-token: ${{ inputs.token }} + # language=js + script: | + // GitHub username grammar: 1-39 chars, alphanumeric + dash, + // no leading or trailing dash, no consecutive dashes. + // Source: https://github.com/shinnn/github-username-regex. + const USERNAME_REGEX = /^[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}$/; + + // Logins that are real GitHub accounts but never represent a + // human committer for our trust-gate purposes: + // - `web-flow` → squash-merge / web-UI commit signer + // - `noreply` → defensive; no real user + // Anything ending in `[bot]` is filtered by USERNAME_REGEX + // (the brackets are not in the username alphabet). + const FILTERED_LOGINS = new Set(['web-flow', 'noreply']); + + const split = (s) => + String(s ?? '') + .split(/[\n,]/) + .map((x) => x.trim()) + .filter(Boolean); + + const explicit = [ + ...split(process.env.EXPLICIT_USERNAME), + ...split(process.env.EXPLICIT_USERNAMES), + ]; + + const collected = []; + + if (process.env.COLLECT_FROM_WORKFLOW_RUN === 'true') { + const run = context.payload?.workflow_run; + if (!run) { + core.info('collect-from-workflow-run is true but no workflow_run payload is present — skipping auto-collection'); + } else { + // The PR opener (upstream-resolved). Falls back to nothing + // if no PR is associated with the run. + try { + const prRef = run.pull_requests?.[0]; + if (prRef?.number) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prRef.number, + }); + if (pr.user?.login) collected.push(pr.user.login); + } + } catch (err) { + core.warning(`Could not resolve PR opener: ${err.message}`); + } + + // The user(s) who triggered the upstream workflow_run. + if (run.actor?.login) collected.push(run.actor.login); + if (run.triggering_actor?.login) collected.push(run.triggering_actor.login); + + // Head commit author/committer — fetched against the + // (possibly fork) head repo. Fail-soft: if we cannot + // resolve the commit, the explicit + actor logins still + // constrain the check. + const headRepoFull = run.head_repository?.full_name; + const headSha = run.head_sha; + if (headRepoFull && headSha) { + const [forkOwner, forkRepo] = headRepoFull.split('/'); + try { + const { data: commit } = await github.rest.repos.getCommit({ + owner: forkOwner, + repo: forkRepo, + ref: headSha, + }); + if (commit.author?.login) collected.push(commit.author.login); + if (commit.committer?.login) collected.push(commit.committer.login); + } catch (err) { + core.warning(`Could not fetch head commit ${headSha} from ${headRepoFull}: ${err.message}`); + } + } + } + } + + // Merge, filter bots, validate against the GitHub username + // grammar, and dedupe. Validation happens before any value is + // interpolated into an API path. + const candidates = [...explicit, ...collected]; + const seen = new Set(); + const checked = []; + for (const raw of candidates) { + if (FILTERED_LOGINS.has(raw)) continue; + if (!USERNAME_REGEX.test(raw)) { + core.info(`Skipping invalid login candidate: ${JSON.stringify(raw)}`); + continue; + } + const key = raw.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + checked.push(raw); + } + + core.setOutput('checked-usernames', checked.join('\n')); + + if (!process.env.ORG_MEMBER_TOKEN) { + core.warning('No token supplied — failing closed (is-member=false)'); + core.setOutput('is-member', 'false'); + core.setOutput('non-members', checked.join('\n')); + return; + } + + if (checked.length === 0) { + core.warning('No valid usernames resolved — failing closed (is-member=false)'); + core.setOutput('is-member', 'false'); + core.setOutput('non-members', ''); + return; + } + + const nonMembers = []; + for (const username of checked) { + try { + await github.rest.orgs.checkMembershipForUser({ + org: 'elastic', + username, + }); + core.info(`${username} is a member of the elastic org`); + } catch (err) { + // Octokit raises a HttpError with status 302 (not visible + // to the caller — public membership check) or 404 (not a + // member) for non-members. Treat any non-200 as "not a + // confirmed member". + const status = err.status ?? 'unknown'; + core.info(`${username} is not a confirmed member of the elastic org (status ${status})`); + nonMembers.push(username); + } + } + + if (nonMembers.length === 0) { + core.setOutput('is-member', 'true'); + core.setOutput('non-members', ''); + } else { + core.setOutput('is-member', 'false'); + core.setOutput('non-members', nonMembers.join('\n')); + }