Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,261 changes: 1,261 additions & 0 deletions .github/workflows/base-std-docs-sync.yml

Large diffs are not rendered by default.

233 changes: 233 additions & 0 deletions scripts/__tests__/verify-oidc.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import test from "node:test";
import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto";
import {
ISSUER,
JWKS_URL,
OidcVerificationError,
fetchGithubJwks,
parseJwt,
pickClaims,
verifyOidcToken,
} from "../verify-oidc.mjs";

const NOW = 2_000_000_000;
const AUDIENCE = "docs-sync:base/docs";
const REPOSITORY = "base/base-std";
const KID = "test-rsa-key";

const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const publicJwk = {
...publicKey.export({ format: "jwk" }),
kid: KID,
alg: "RS256",
use: "sig",
key_ops: ["verify"],
};
const JWKS = { keys: [publicJwk] };

function encode(value) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}

function defaultPayload(overrides = {}) {
return {
iss: ISSUER,
aud: AUDIENCE,
repository: REPOSITORY,
repository_owner: "base",
workflow_ref: `${REPOSITORY}/.github/workflows/docs-pr-dispatch.yml@refs/heads/main`,
iat: NOW - 30,
exp: NOW + 600,
...overrides,
};
}

function makeToken({ header = {}, payload = {}, signingKey = privateKey } = {}) {
const encodedHeader = encode({ alg: "RS256", kid: KID, typ: "JWT", ...header });
const encodedPayload = encode(defaultPayload(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = sign("RSA-SHA256", Buffer.from(signingInput, "ascii"), signingKey)
.toString("base64url");
return `${signingInput}.${signature}`;
}

async function expectCode(promise, code) {
await assert.rejects(promise, (error) => {
assert.ok(error instanceof OidcVerificationError);
assert.equal(error.code, code);
return true;
});
}

function verify(token, overrides = {}) {
return verifyOidcToken({
token,
expectedAudience: AUDIENCE,
expectedRepository: REPOSITORY,
requireMainWorkflow: true,
jwks: JWKS,
nowSeconds: NOW,
...overrides,
});
}

test("accepts a valid GitHub-style token", async () => {
const result = await verify(makeToken());
assert.equal(result.payload.repository, REPOSITORY);
assert.equal(result.protectedHeader.kid, KID);
assert.equal(result.ageSeconds, 30);
assert.equal(pickClaims(result.payload).repository_owner, "base");
});

test("accepts an audience array containing the expected audience", async () => {
await verify(makeToken({ payload: { aud: ["another-audience", AUDIENCE] } }));
});

test("rejects malformed JWT shapes and encodings", async () => {
await expectCode(verify("not-a-jwt"), "malformed_token");
assert.throws(() => parseJwt("a.b.="), (error) => error.code === "malformed_token");
});

test("rejects non-RS256 algorithms before key verification", async () => {
await expectCode(verify(makeToken({ header: { alg: "none" } })), "unsupported_algorithm");
});

test("rejects a missing kid", async () => {
const token = makeToken({ header: { kid: "" } });
await expectCode(verify(token), "malformed_token");
});

test("rejects tampered signatures", async () => {
const token = makeToken();
const [header, payload, signature] = token.split(".");
const changedPayload = encode({ ...defaultPayload(), repository: "attacker/base-std" });
await expectCode(verify(`${header}.${changedPayload}.${signature}`), "signature_invalid");
});

test("rejects unknown, duplicate, and incompatible signing keys", async () => {
await expectCode(
verify(makeToken(), { jwks: { keys: [{ ...publicJwk, kid: "different" }] } }),
"unknown_signing_key",
);
await expectCode(
verify(makeToken(), { jwks: { keys: [publicJwk, { ...publicJwk }] } }),
"ambiguous_signing_key",
);
await expectCode(
verify(makeToken(), { jwks: { keys: [{ ...publicJwk, kty: "EC" }] } }),
"unsupported_signing_key",
);
});

test("rejects wrong issuer, audience, and repository claims", async () => {
await expectCode(
verify(makeToken({ payload: { iss: "https://issuer.example" } })),
"claim_validation_failed",
);
await expectCode(
verify(makeToken({ payload: { aud: "docs-sync:other/docs" } })),
"claim_validation_failed",
);
await expectCode(
verify(makeToken({ payload: { repository: "attacker/base-std" } })),
"repository_mismatch",
);
});

test("requires numeric exp and iat claims", async () => {
await expectCode(
verify(makeToken({ payload: { exp: undefined } })),
"claim_validation_failed",
);
await expectCode(
verify(makeToken({ payload: { iat: "not-a-number" } })),
"claim_validation_failed",
);
});

test("rejects expired and not-yet-active tokens", async () => {
await expectCode(
verify(makeToken({ payload: { exp: NOW } })),
"claim_validation_failed",
);
await expectCode(
verify(makeToken({ payload: { nbf: NOW + 1 } })),
"claim_validation_failed",
);
});

test("enforces issue-time future tolerance and replay-age limit", async () => {
await expectCode(
verify(makeToken({ payload: { iat: NOW + 31 } })),
"iat_in_future",
);
await expectCode(
verify(makeToken({ payload: { iat: NOW - 601 } })),
"token_too_old",
);
});

test("enforces the expected main-branch workflow_ref", async () => {
await expectCode(
verify(makeToken({
payload: {
workflow_ref: `${REPOSITORY}/.github/workflows/docs-pr-dispatch.yml@refs/heads/feature`,
},
})),
"workflow_ref_not_on_main",
);
await expectCode(
verify(makeToken({
payload: {
workflow_ref: `${REPOSITORY}/.github/workflows/nested/dispatch.yml@refs/heads/main`,
},
})),
"workflow_ref_not_on_main",
);
});

test("can omit the optional workflow_ref policy", async () => {
await verify(makeToken({ payload: { workflow_ref: undefined } }), {
requireMainWorkflow: false,
});
});

test("fetchGithubJwks uses the fixed GitHub endpoint and strict fetch options", async () => {
let call;
const result = await fetchGithubJwks({
fetchImpl: async (url, options) => {
call = { url, options };
return {
ok: true,
status: 200,
text: async () => JSON.stringify(JWKS),
};
},
});
assert.equal(call.url, JWKS_URL);
assert.equal(call.options.method, "GET");
assert.equal(call.options.redirect, "error");
assert.equal(call.options.headers.Accept, "application/json");
assert.ok(call.options.signal instanceof AbortSignal);
assert.deepEqual(result, JWKS);
});

test("fetchGithubJwks fails closed on network, HTTP, and body errors", async () => {
await expectCode(
fetchGithubJwks({ fetchImpl: async () => { throw new Error("offline"); } }),
"jwks_fetch_failed",
);
await expectCode(
fetchGithubJwks({
fetchImpl: async () => ({ ok: false, status: 503, text: async () => "" }),
}),
"jwks_fetch_failed",
);
await expectCode(
fetchGithubJwks({
fetchImpl: async () => ({ ok: true, status: 200, text: async () => "not-json" }),
}),
"jwks_invalid",
);
});
115 changes: 115 additions & 0 deletions scripts/lib/workflow-fail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# shellcheck shell=bash
#
# workflow-fail.sh — shared fail helper for the docs-sync receiver workflow.
#
# Every rejection point in the apply job sources this file and calls
# workflow_fail "<step name>" "<reason>"
# to produce a uniform record across error annotations and the run summary.
# Operators get one shape to read; security reviewers get one place to look.
#
# Reads dispatch context from these env vars (each is optional — empty
# values render as "(unknown)" in the summary so the helper is safe to
# call before all of them are populated):
#
# SENDER_LOGIN github.event.sender.login (only authoritative id)
# PAYLOAD_SOURCE_REPO claimed source repo from client_payload
# PAYLOAD_SHA claimed SHA from client_payload
# PAYLOAD_PR_NUMBER claimed PR number from client_payload
# GITHUB_RUN_ID injected by Actions
# GITHUB_STEP_SUMMARY injected by Actions; if unset, summary write is skipped
#
# Usage:
# source "${GITHUB_WORKSPACE}/scripts/lib/workflow-fail.sh"
# if [[ ! "$thing" =~ $pattern ]]; then
# workflow_fail "Validate payload schema" "thing '${thing}' does not match ${pattern}"
# fi
#
# Exits the calling step with status 1. Never returns. Assumes jq is on PATH
# (every step that sources this helper already invokes jq elsewhere).
#
# This file does not set shell options — the caller owns set -euo pipefail
# state and we must not mutate it on source.

# Idempotent guard — if a step sources the helper twice we keep the first
# definitions. The function-existence test avoids redefining workflow_fail.
if declare -F workflow_fail > /dev/null 2>&1; then
return 0 2>/dev/null || true
fi

# GitHub workflow command annotation. The spec requires \n and \r in the
# message to be percent-encoded; otherwise multi-line reasons truncate at
# the first newline and the operator sees a half-message.
_wf_emit_annotation() {
local step="$1" reason="$2"
local safe="${reason//$'\r'/%0D}"
safe="${safe//$'\n'/%0A}"
printf '::error title=%s::%s\n' "$step" "$safe" >&2
}

# Markdown table row in $GITHUB_STEP_SUMMARY. Pipes inside cells must be
# escaped as \| in GitHub-flavored markdown; newlines collapse to spaces.
_wf_emit_step_summary() {
local step="$1" reason="$2"
local summary_file="${GITHUB_STEP_SUMMARY:-}"
[[ -z "$summary_file" ]] && return 0
local safe="${reason//|/\\|}"
safe="${safe//$'\n'/ }"
local ts
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
printf '\n### Dispatch rejected: %s\n\n' "$step"
printf '| Field | Value |\n'
printf '|---|---|\n'
printf '| Reason | %s |\n' "$safe"
printf '| Sender (github.event.sender.login) | `%s` |\n' "${SENDER_LOGIN:-(unknown)}"
printf '| Source repo (claimed) | `%s` |\n' "${PAYLOAD_SOURCE_REPO:-(unknown)}"
printf '| SHA (claimed) | `%s` |\n' "${PAYLOAD_SHA:-(unknown)}"
printf '| PR number (claimed) | `%s` |\n' "${PAYLOAD_PR_NUMBER:-(none)}"
printf '| Run ID | `%s` |\n' "${GITHUB_RUN_ID:-(unknown)}"
printf '| Timestamp | `%s` |\n' "$ts"
} >> "$summary_file"
}

# Structured JSON log line on stderr — for alerting pipelines that scrape
# workflow logs. Per workspace rule: structured JSON, timestamp, no PII.
# Sender login is a public GitHub handle that already appears in GitHub's
# own audit-log surface.
_wf_emit_json_log() {
local step="$1" reason="$2"
local ts
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
jq -nc \
--arg ts "$ts" \
--arg step "$step" \
--arg reason "$reason" \
--arg sender "${SENDER_LOGIN:-}" \
--arg source_repo "${PAYLOAD_SOURCE_REPO:-}" \
--arg sha "${PAYLOAD_SHA:-}" \
--arg pr_number "${PAYLOAD_PR_NUMBER:-}" \
--arg run_id "${GITHUB_RUN_ID:-}" \
'{
timestamp: $ts,
level: "error",
component: "docs-sync-receiver",
event: "dispatch_rejected",
step: $step,
reason: $reason,
sender_login: $sender,
source_repo_claimed: $source_repo,
sha_claimed: $sha,
pr_number_claimed: $pr_number,
github_run_id: $run_id
}' >&2
}

# Public entrypoint. Always exits non-zero — the caller never returns from
# this. Order: annotation first (operator's eye), summary second (post-
# mortem), JSON log third (alerting pipeline).
workflow_fail() {
local step="${1:-unknown step}"
local reason="${2:-no reason provided}"
_wf_emit_annotation "$step" "$reason"
_wf_emit_step_summary "$step" "$reason"
_wf_emit_json_log "$step" "$reason"
exit 1
}
Loading
Loading