From e3ff7cbee2da04300e346048b0bab41cc5d98c30 Mon Sep 17 00:00:00 2001 From: Really Him Date: Sun, 30 Aug 2026 03:52:31 -0400 Subject: [PATCH 1/2] feat: add credential-free fixture dev server --- .env.example | 14 +++++- .gitignore | 7 ++- CONTRIBUTING.md | 30 ++++++++++++- docs/operations.md | 6 +-- package.json | 1 + src/config/env.ts | 26 +++++++++-- src/dev.ts | 12 +++++ src/dev/fixture-app.ts | 86 ++++++++++++++++++++++++++++++++++++ test/config/env.test.ts | 20 ++++++++- test/dev/fixture-app.test.ts | 43 ++++++++++++++++++ 10 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 src/dev.ts create mode 100644 src/dev/fixture-app.ts create mode 100644 test/dev/fixture-app.test.ts diff --git a/.env.example b/.env.example index 43a4ba2..1540ed2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,16 @@ -# Local development defaults only. Do not store production credentials here. +# Copy this file to .env for authenticated local development. +# Never commit real GitHub App credentials. + +# Required by `npm run dev`. +GITHUB_APP_ID= +GITHUB_PRIVATE_KEY_BASE64= +GITHUB_WEBHOOK_SECRET= + +# GITHUB_PRIVATE_KEY may be used instead of GITHUB_PRIVATE_KEY_BASE64. Store a +# PEM key on one quoted line with newlines written as \n characters. +# GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" + +# Optional local overrides. PORT=3000 CACHE_TTL_SECONDS=3600 GITHUB_API_BASE_URL=https://api.github.com diff --git a/.gitignore b/.gitignore index 8db94a3..2af51df 100644 --- a/.gitignore +++ b/.gitignore @@ -95,10 +95,9 @@ pids/ # Environment variables .env -.env.local -.env.development.local -.env.test.local -.env.production.local +.env.* +!.env.example +*.private-key.pem # Build outputs dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95149df..a1776a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,12 +40,38 @@ npm ci Use `npm install` only when intentionally updating dependencies and committing the resulting lockfile change. (Don't mix dependency updates with feature changes unless otherwise necessary.) -Run the development server: +Run the credential-free fixture server: ```bash +npm run dev:fixtures +``` + +This starts the real HTTP routes, cache, badge evaluators, and response renderers against deterministic in-process GitHub responses. It never contacts GitHub, does not mount the webhook route, and does not require access to the PolicyChecks GitHub App. You can use any owner and repository names in local URLs, for example: + +```text +http://localhost:3000/github/example/project/info.json +http://localhost:3000/github/example/project/sha-pinning-required.svg +``` + +Tests are also credential-free and mock GitHub at the application boundaries. + +Maintainers who need to exercise live GitHub App authentication can copy the environment template, populate the ignored file with credentials from their credentials manager, and run the authenticated server: + +```bash +cp .env.example .env npm run dev ``` +`GITHUB_APP_ID`, either `GITHUB_PRIVATE_KEY_BASE64` or `GITHUB_PRIVATE_KEY`, and `GITHUB_WEBHOOK_SECRET` are required. The repository used in a request must have the corresponding GitHub App installed. Never give the hosted PolicyChecks private key or webhook secret to outside contributors, and never commit `.env`. + +Base64 is the least error-prone representation for a private key in `.env`. For example, this prints a PEM file as a single line that can be pasted after `GITHUB_PRIVATE_KEY_BASE64=`: + +```bash +openssl base64 -A -in /path/to/github-app.private-key.pem +``` + +An outside contributor who needs to test live installation and authentication behavior should create and install their own development GitHub App with repository `Administration: Read` permission. A personal access token does not exercise PolicyChecks' App installation lookup and is not supported as a substitute. + Run the standard verification commands: ```bash @@ -60,4 +86,4 @@ The full local check used by CI is: npm run check ``` -`.env.example` contains non-secret local defaults only. Tests do not require GitHub credentials. If authenticated local development is needed, copy `.env.example` to the ignored `.env` file and populate credentials from your own credentials manager. Do not commit credentials. +`.env.example` contains blank credential placeholders and non-secret local defaults. It does not contain usable credentials. diff --git a/docs/operations.md b/docs/operations.md index a904a4e..f67cda2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -168,7 +168,7 @@ The reintroduced processor must still verify signatures before parsing, avoid Gi ## Credential Storage -Production credentials are Cloudflare Worker secrets, backed by the operator's credentials manager and the GitHub App settings UI. Do not put GitHub App credentials or webhook secrets in `.env.example`, committed Markdown files, GitHub Actions variables, or repository files. +Production credentials are Cloudflare Worker secrets, backed by the operator's credentials manager and the GitHub App settings UI. Do not put credential values or webhook secrets in `.env.example`, committed Markdown files, GitHub Actions variables, or repository files. Badge users and GitHub App installers do not need access to these credentials. The private key and webhook secret belong to the hosted PolicyChecks GitHub App and are only for the maintainers operating the service. @@ -180,6 +180,6 @@ GITHUB_PRIVATE_KEY_BASE64 GITHUB_WEBHOOK_SECRET ``` -`.env.example` contains non-secret local defaults only. It documents runtime configuration shape; it is not a secret template and should not be filled with production values. +`.env.example` documents the runtime configuration shape with blank credential placeholders and non-secret local defaults. Copy it to the ignored `.env` file only when authenticated local development is needed, then populate `.env` from the operator's credentials manager. -`GITHUB_PRIVATE_KEY` is supported for local development, but `GITHUB_PRIVATE_KEY_BASE64` is preferred for Cloudflare because it avoids newline transport issues. Local `.env` files are ignored and optional. They should only be populated from the operator's credentials manager when a maintainer intentionally needs to exercise authenticated GitHub paths locally. Ordinary tests and contributor setup do not require GitHub credentials. +`GITHUB_PRIVATE_KEY` is supported for local development, but `GITHUB_PRIVATE_KEY_BASE64` is preferred because it avoids newline transport issues both locally and in Cloudflare. Local `.env` files are ignored and optional. They should only be populated from the operator's credentials manager when a maintainer intentionally needs to exercise authenticated GitHub paths locally. Ordinary tests and `npm run dev:fixtures` do not require GitHub credentials. diff --git a/package.json b/package.json index 3a5739f..bd8ec0c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "dev": "tsx src/app.ts", + "dev:fixtures": "tsx src/dev.ts", "build": "tsc -p tsconfig.build.json", "start": "node dist/app.js", "test": "vitest run", diff --git a/src/config/env.ts b/src/config/env.ts index cba30a4..d5a3956 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -10,14 +10,19 @@ export interface RuntimeConfig { }; } +export interface ServerConfig { + port: number; + cacheTtlMs: number; +} + export function loadConfig(env: NodeJS.ProcessEnv = process.env): RuntimeConfig { + const server = loadServerConfig(env); const appId = parseRequiredInteger(env.GITHUB_APP_ID, "GITHUB_APP_ID"); const privateKey = readPrivateKey(env); const webhookSecret = parseRequiredString(env.GITHUB_WEBHOOK_SECRET, "GITHUB_WEBHOOK_SECRET"); return { - port: parseOptionalInteger(env.PORT, 3000, "PORT"), - cacheTtlMs: parseOptionalInteger(env.CACHE_TTL_SECONDS, 3600, "CACHE_TTL_SECONDS") * 1000, + ...server, github: { appId, privateKey, @@ -28,9 +33,22 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): RuntimeConfig }; } +export function loadServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + return { + port: parseOptionalInteger(env.PORT, 3000, "PORT"), + cacheTtlMs: parseOptionalInteger(env.CACHE_TTL_SECONDS, 3600, "CACHE_TTL_SECONDS") * 1000 + }; +} + function readPrivateKey(env: NodeJS.ProcessEnv): string { - if (env.GITHUB_PRIVATE_KEY_BASE64 !== undefined) { - return Buffer.from(env.GITHUB_PRIVATE_KEY_BASE64, "base64").toString("utf8"); + if (env.GITHUB_PRIVATE_KEY_BASE64 !== undefined && env.GITHUB_PRIVATE_KEY_BASE64.trim() !== "") { + const decoded = Buffer.from(env.GITHUB_PRIVATE_KEY_BASE64, "base64").toString("utf8"); + + if (decoded.trim() === "") { + throw new Error("GITHUB_PRIVATE_KEY_BASE64 must decode to a non-empty private key."); + } + + return decoded; } if (env.GITHUB_PRIVATE_KEY === undefined || env.GITHUB_PRIVATE_KEY.trim() === "") { diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..af273a8 --- /dev/null +++ b/src/dev.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; + +import { loadServerConfig } from "./config/env.js"; +import { createFixtureApp } from "./dev/fixture-app.js"; + +const config = loadServerConfig(); +const app = createFixtureApp(config.cacheTtlMs); + +app.listen(config.port, () => { + console.log(`policychecks fixture server listening on http://localhost:${config.port}`); + console.log("GitHub API calls and webhook handling are disabled in fixture mode."); +}); diff --git a/src/dev/fixture-app.ts b/src/dev/fixture-app.ts new file mode 100644 index 0000000..50bcf23 --- /dev/null +++ b/src/dev/fixture-app.ts @@ -0,0 +1,86 @@ +import { InMemoryBadgeCache } from "../cache/cache.js"; +import type { GitHubClient } from "../github/client.js"; +import type { InstallationResolution } from "../github/installations.js"; +import { BadgeService, type InstallationResolver } from "../server/badge-service.js"; +import { createHttpApp } from "../server/http-app.js"; + +const fixtureGitHub: GitHubClient = { + async getRepository() { + return { + id: 1, + default_branch: "main", + web_commit_signoff_required: true, + security_and_analysis: { + secret_scanning: { status: "enabled" }, + secret_scanning_push_protection: { status: "disabled" } + } + }; + }, + async getImmutableReleases() { + return { + enabled: true, + enforced_by_owner: false + }; + }, + async getActionsPermissions() { + return { + sha_pinning_required: true + }; + }, + async getBranchRules() { + return [ + { type: "deletion" }, + { type: "non_fast_forward" }, + { type: "pull_request", parameters: { required_approving_review_count: 1 } }, + { type: "required_linear_history" }, + { type: "required_signatures" }, + { + type: "required_status_checks", + parameters: { required_status_checks: [{ context: "test" }] } + } + ]; + }, + async getCommunityProfile() { + return { + health_percentage: 75, + files: { + code_of_conduct: { name: "Contributor Covenant", key: "contributor_covenant" }, + contributing: {}, + license: { name: "MIT License", key: "mit", spdx_id: "MIT" }, + readme: {} + }, + content_reports_enabled: false, + updated_at: null + }; + } +}; + +const fixtureResolver: InstallationResolver = { + async resolve(owner: string, repo: string): Promise { + const now = new Date().toISOString(); + + return { + status: "ok", + github: fixtureGitHub, + repository: { + owner, + repo, + repositoryId: 1, + installationId: 1, + defaultBranch: "main", + createdAt: now, + updatedAt: now + } + }; + } +}; + +export function createFixtureApp(cacheTtlMs: number) { + const badgeService = new BadgeService({ + cache: new InMemoryBadgeCache(), + installationResolver: fixtureResolver, + cacheTtlMs + }); + + return createHttpApp(badgeService); +} diff --git a/test/config/env.test.ts b/test/config/env.test.ts index 5ef2ff9..5cccee5 100644 --- a/test/config/env.test.ts +++ b/test/config/env.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { loadConfig } from "../../src/config/env.js"; +import { loadConfig, loadServerConfig } from "../../src/config/env.js"; const baseEnv = { GITHUB_APP_ID: "12345", @@ -58,9 +58,16 @@ describe("loadConfig", () => { expect(config.github.privateKey).toBe("a\nb\nc"); }); + it("uses the raw private key when the base64 placeholder is empty", () => { + const config = loadConfig({ ...baseEnv, GITHUB_PRIVATE_KEY_BASE64: "" }); + + expect(config.github.privateKey).toBe("line-1\nline-2"); + }); + it.each([ ["missing", { GITHUB_APP_ID: "1" }], - ["empty", { GITHUB_APP_ID: "1", GITHUB_PRIVATE_KEY: " " }] + ["empty", { GITHUB_APP_ID: "1", GITHUB_PRIVATE_KEY: " " }], + ["empty base64", { GITHUB_APP_ID: "1", GITHUB_PRIVATE_KEY_BASE64: " " }] ])("throws when the private key is %s", (_label, env) => { expect(() => loadConfig(env as NodeJS.ProcessEnv)).toThrow(/GITHUB_PRIVATE_KEY/); }); @@ -103,3 +110,12 @@ describe("loadConfig", () => { ); }); }); + +describe("loadServerConfig", () => { + it("does not require GitHub credentials", () => { + expect(loadServerConfig({})).toEqual({ + port: 3000, + cacheTtlMs: 3_600_000 + }); + }); +}); diff --git a/test/dev/fixture-app.test.ts b/test/dev/fixture-app.test.ts new file mode 100644 index 0000000..0eaeb13 --- /dev/null +++ b/test/dev/fixture-app.test.ts @@ -0,0 +1,43 @@ +import request from "supertest"; +import { describe, expect, it } from "vitest"; + +import { createFixtureApp } from "../../src/dev/fixture-app.js"; + +describe("fixture development app", () => { + const app = createFixtureApp(3_600_000); + + it("starts without GitHub credentials", async () => { + await request(app).get("/healthz").expect(200).expect({ ok: true }); + }); + + it("evaluates badges through deterministic GitHub fixtures", async () => { + const response = await request(app).get("/github/example/project/info.json").expect(200); + + expect(response.body.badges).toHaveLength(12); + expect(response.body.badges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ badgeId: "immutable-releases", result: "enabled" }), + expect.objectContaining({ badgeId: "secret-push-protection-enabled", result: "disabled" }), + expect.objectContaining({ badgeId: "community-health", result: "75/100" }) + ]) + ); + }); + + it("renders fixture badge SVGs", async () => { + const response = await request(app) + .get("/github/example/project/sha-pinning-required.svg") + .expect(200) + .expect("Content-Type", /image\/svg\+xml/); + + const svg = response.text ?? response.body.toString("utf8"); + expect(svg).toContain("SHA pinning"); + expect(svg).toContain("enabled"); + }); + + it("does not expose the authenticated webhook route", async () => { + await request(app) + .post("/github/webhook") + .send({ zen: "Keep it logically awesome." }) + .expect(404); + }); +}); From 3dc491aae468d19f329e6f1cc3f3d70662c43c66 Mon Sep 17 00:00:00 2001 From: Really Him Date: Mon, 31 Aug 2026 19:49:29 -0400 Subject: [PATCH 2/2] test: cover empty decoded private key --- test/config/env.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/config/env.test.ts b/test/config/env.test.ts index 5cccee5..309830d 100644 --- a/test/config/env.test.ts +++ b/test/config/env.test.ts @@ -64,6 +64,15 @@ describe("loadConfig", () => { expect(config.github.privateKey).toBe("line-1\nline-2"); }); + it("throws when the base64 private key decodes to whitespace", () => { + expect(() => + loadConfig({ + ...baseEnv, + GITHUB_PRIVATE_KEY_BASE64: Buffer.from(" ", "utf8").toString("base64") + }) + ).toThrow("GITHUB_PRIVATE_KEY_BASE64 must decode to a non-empty private key."); + }); + it.each([ ["missing", { GITHUB_APP_ID: "1" }], ["empty", { GITHUB_APP_ID: "1", GITHUB_PRIVATE_KEY: " " }],