diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1d3ba4584..0427c92ef 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -117,9 +117,18 @@ jobs: - name: Audit production environment deployment protections env: - GH_TOKEN: ${{ github.token }} + DELEGATED_MAINTAINER_TOKEN: ${{ github.token }} NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH: artifacts/governance/production-environment-governance.json - run: npm run production:governance + run: | + set -euo pipefail + umask 077 + token_dir="$(mktemp -d "$RUNNER_TEMP/noema-production-governance.XXXXXX")" + token_path="$token_dir/maintainer-app-token" + trap 'rm -rf "$token_dir"' EXIT + printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path" + chmod 0600 "$token_path" + unset DELEGATED_MAINTAINER_TOKEN + NOEMA_MAINTAINER_TOKEN_PATH="$token_path" npm run production:governance - name: Production evidence preflight env: @@ -250,7 +259,7 @@ jobs: repository: $repository, releaseTag: $releaseTag, commitSha: $commitSha, - deploymentEvidenceSha256: $deploymentEvidenceSha256, + deploymentEvidenceSha256: $deployment_digest, signerWorkflow: $signerWorkflow, predicateType: $predicateType, oidcIssuer: $oidcIssuer, diff --git a/scripts/production-environment-governance-audit.mjs b/scripts/production-environment-governance-audit.mjs index 66777a83c..d4ef17971 100644 --- a/scripts/production-environment-governance-audit.mjs +++ b/scripts/production-environment-governance-audit.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { readDelegatedGithubToken as readHardenedDelegatedGithubToken } from "./lib/delegated-github-token.mjs"; import { evaluateProductionEnvironment } from "./lib/production-environment-governance.mjs"; import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; @@ -36,10 +37,21 @@ export function redactSensitiveValue(value, sensitiveValues = []) { return redacted; } +/** + * Read a short-lived GitHub credential from the repository's hardened, + * descriptor-safe capability-file boundary. + * + * @param {unknown} tokenPath Explicit non-secret capability-file path. + * @returns {string} Exact delegated GitHub token bytes decoded as UTF-8. + */ +export function readDelegatedGithubToken(tokenPath) { + return readHardenedDelegatedGithubToken(tokenPath); +} + /** * Build the least-authority environment passed to the read-only GitHub CLI. * - * @param {NodeJS.ProcessEnv} [sourceEnvironment=process.env] Ambient process environment. + * @param {NodeJS.ProcessEnv} [sourceEnvironment=process.env] Explicit credential/config source. * @returns {Record} Allow-listed child-process environment. */ export function createGhSubprocessEnvironment(sourceEnvironment = process.env) { @@ -74,9 +86,9 @@ export function decodeGhOutput(value, label = "output") { /** * Execute one bounded GitHub CLI request for production-governance evidence. - * Runtime callers use the real shell-free spawn implementation and current - * process environment. Tests may inject only these two boundaries so failure - * byte selection, UTF-8 handling, and secret redaction are exercised directly. + * Runtime callers pass only an explicitly read delegated credential; tests may + * inject the subprocess primitive and an explicit environment to exercise the + * error/redaction boundary without granting network or ambient secret access. * * @param {string[]} args GitHub CLI arguments. * @param {{sourceEnvironment?: NodeJS.ProcessEnv, spawnSyncImpl?: typeof spawnSync}} [options] @@ -115,15 +127,21 @@ export function runGh( return decodeGhOutput(completed.stdout, "stdout").trim(); } -function collectEnvironment(repository, runGhImpl) { - const raw = runGhImpl([ +function collectEnvironment(repository, runGhImpl, delegatedGithubToken, sourceEnvironment) { + const args = [ "api", "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2026-03-10", `repos/${repository}/environments/production`, - ]); + ]; + const raw = runGhImpl(args, { + sourceEnvironment: { + PATH: sourceEnvironment.PATH, + GH_TOKEN: delegatedGithubToken, + }, + }); if (!raw) { throw new Error("GitHub CLI returned an empty production environment response."); } @@ -203,9 +221,9 @@ function buildFailureReport(repository, error) { /** * Collect and evaluate the live production-environment governance evidence. - * Dependency injection is deliberately limited to the read-only GitHub CLI, - * process environment, logging sink, and exit-code sink so realistic tests can - * exercise every evidence boundary without granting network or write authority. + * The production entrypoint requires a descriptor-safe delegated credential. + * Dependency-injected GitHub clients remain credential-free test seams so + * malformed/hostile evidence can be exercised without network authority. * * @param {{ * sourceEnvironment?: NodeJS.ProcessEnv, @@ -229,12 +247,21 @@ export function main( const reportPath = String( sourceEnvironment.NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH ?? defaultReportPath, ).trim() || defaultReportPath; + const tokenPath = String(sourceEnvironment.NOEMA_MAINTAINER_TOKEN_PATH ?? "").trim(); let report; try { if (!repositoryPattern.test(repository)) { throw new Error("GITHUB_REPOSITORY must identify a ContextualWisdomLab repository."); } - const environment = collectEnvironment(repository, runGhImpl); + const delegatedGithubToken = runGhImpl === runGh + ? readDelegatedGithubToken(tokenPath) + : null; + const environment = collectEnvironment( + repository, + runGhImpl, + delegatedGithubToken, + sourceEnvironment, + ); const evaluation = evaluateProductionEnvironment(environment); report = { schema_version: 1, diff --git a/test/production-environment-governance-token-capability.test.ts b/test/production-environment-governance-token-capability.test.ts new file mode 100644 index 000000000..bcfc361a2 --- /dev/null +++ b/test/production-environment-governance-token-capability.test.ts @@ -0,0 +1,114 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + main, + readDelegatedGithubToken, +} from "../scripts/production-environment-governance-audit.mjs"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), "noema-production-governance-token-")); + temporaryDirectories.push(directory); + return directory; +} + +function tokenFile(mode = 0o600) { + const directory = temporaryDirectory(); + const path = join(directory, "maintainer-app-token"); + writeFileSync(path, "short-lived-maintainer-token", { encoding: "utf8", mode }); + chmodSync(path, mode); + return path; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("production environment governance GitHub credential ingress", () => { + it("reads only an owner-only delegated capability file", () => { + expect(readDelegatedGithubToken(tokenFile())).toBe("short-lived-maintainer-token"); + + expect(() => readDelegatedGithubToken(tokenFile(0o640))).toThrow( + "Maintainer token file permissions must be owner-only.", + ); + }); + + it("refuses a symlinked delegated token capability", () => { + const directory = temporaryDirectory(); + const target = join(directory, "real-token"); + const link = join(directory, "token-link"); + writeFileSync(target, "short-lived-maintainer-token", { encoding: "utf8", mode: 0o600 }); + symlinkSync(target, link); + + expect(() => readDelegatedGithubToken(link)).toThrow( + "Maintainer token file could not be opened safely:", + ); + }); + + it("fails closed instead of falling back to an ambient GH_TOKEN", () => { + const directory = temporaryDirectory(); + const report = main({ + sourceEnvironment: { + GITHUB_REPOSITORY: "ContextualWisdomLab/noema", + GH_TOKEN: "ambient-token-must-not-be-used", + NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH: join(directory, "report.json"), + }, + log: () => undefined, + setExitCode: () => undefined, + }); + + expect(report).toMatchObject({ status: "FAIL" }); + expect(report.failures[0]).toMatchObject({ + code: "production_environment_collection_failed", + detail: "Maintainer token file path is required.", + }); + expect(readFileSync(join(directory, "report.json"), "utf8")).not.toContain( + "ambient-token-must-not-be-used", + ); + }); + + it("bootstraps the deployment audit through the token capability instead of GH_TOKEN", () => { + const workflow = readFileSync(".github/workflows/cd.yml", "utf8"); + const auditStart = workflow.indexOf("- name: Audit production environment deployment protections"); + const auditEnd = workflow.indexOf("- name: Production evidence preflight", auditStart); + const auditStep = workflow.slice(auditStart, auditEnd); + + expect(auditStart).toBeGreaterThan(-1); + expect(auditEnd).toBeGreaterThan(auditStart); + expect(auditStep).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(auditStep).toContain("umask 077"); + expect(auditStep).toContain("chmod 0600"); + expect(auditStep).not.toContain("GH_TOKEN: ${{ github.token }}"); + }); + + it("creates a fresh private directory before writing delegated token bytes", () => { + const workflow = readFileSync(".github/workflows/cd.yml", "utf8"); + const auditStart = workflow.indexOf("- name: Audit production environment deployment protections"); + const auditEnd = workflow.indexOf("- name: Production evidence preflight", auditStart); + const auditStep = workflow.slice(auditStart, auditEnd); + const umaskIndex = auditStep.indexOf("umask 077"); + const mktempIndex = auditStep.indexOf( + 'token_dir="$(mktemp -d "$RUNNER_TEMP/noema-production-governance.XXXXXX")"', + ); + + expect(auditStart).toBeGreaterThan(-1); + expect(auditEnd).toBeGreaterThan(auditStart); + expect(umaskIndex).toBeGreaterThan(-1); + expect(mktempIndex).toBeGreaterThan(umaskIndex); + expect(auditStep).not.toContain('token_dir="$RUNNER_TEMP/noema-production-governance"'); + expect(auditStep).not.toContain('mkdir -p "$token_dir"'); + expect(auditStep).toContain("trap 'rm -rf \"$token_dir\"' EXIT"); + }); +});