Skip to content
Merged
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
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 3 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
30 changes: 28 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
6 changes: 3 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 22 additions & 4 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() === "") {
Expand Down
12 changes: 12 additions & 0 deletions src/dev.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
86 changes: 86 additions & 0 deletions src/dev/fixture-app.ts
Original file line number Diff line number Diff line change
@@ -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<InstallationResolution> {
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);
}
29 changes: 27 additions & 2 deletions test/config/env.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -58,9 +58,25 @@ 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("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: " " }]
["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/);
});
Expand Down Expand Up @@ -103,3 +119,12 @@ describe("loadConfig", () => {
);
});
});

Comment thread
hesreallyhim marked this conversation as resolved.
describe("loadServerConfig", () => {
it("does not require GitHub credentials", () => {
expect(loadServerConfig({})).toEqual({
port: 3000,
cacheTtlMs: 3_600_000
});
});
});
43 changes: 43 additions & 0 deletions test/dev/fixture-app.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});