require OIDC verification for DB secrets on run-context (#538)

* add wiki/betterstack.md documenting log querying, request-ID grouping, and MCP usage

Made-with: Cursor

* fix webhook race conditions: separate runId assignment from data updates

the workflow_run webhook handler had a race where concurrent handlers assigned
the same runId to different pending records. the loser's P2002 silently dropped
data updates (jobId, status, completedAt). fix by splitting into two steps:
assignRunId() handles the race-safe unique assignment, then data updates always
target where: { runId } so they hit the correct record regardless of who won.

also downgrade R2 ObjectLockedByBucketPolicy errors from error to warn level
since duplicate webhook deliveries writing the same key is expected under load.

Made-with: Cursor

* require OIDC verification for DB secrets on run-context endpoint

DB secrets transported via run-context were accessible to any GitHub API
token with read access, bypassing GitHub Actions' fork PR secret isolation.
Now the endpoint requires a valid GitHub Actions OIDC token
(X-GitHub-OIDC-Token header) with a matching repository claim before
returning dbSecrets. Also requires admin for account-scope CLI secret
writes (matching the dashboard), and removes dead redactSecrets code.

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-04-14 20:15:31 +00:00
committed by pullfrog[bot]
parent a4c7c0fc15
commit c86752cf1d
3 changed files with 20 additions and 44 deletions
+10 -4
View File
@@ -57,18 +57,24 @@ const defaultRunContext: RunContext = {
export async function fetchRunContext(params: {
token: string;
repoContext: RepoContext;
oidcToken?: string | undefined;
}): Promise<RunContext> {
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers: Record<string, string> = {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
};
if (params.oidcToken) {
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
}
const response = await apiFetch({
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
headers: {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
},
headers,
signal: controller.signal,
});
+9 -1
View File
@@ -1,3 +1,4 @@
import * as core from "@actions/core";
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
@@ -32,9 +33,16 @@ export async function resolveRunContextData(
const repoContext = parseRepoContext();
let oidcToken: string | undefined;
try {
oidcToken = await core.getIDToken("pullfrog-api");
} catch {
// OIDC not available (local dev, non-actions environment, fork PRs)
}
const [repoResponse, runContext] = await Promise.all([
params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }),
fetchRunContext({ token: params.token, repoContext }),
fetchRunContext({ token: params.token, repoContext, oidcToken }),
]);
return {
+1 -39
View File
@@ -1,10 +1,7 @@
/**
* Secret detection and redaction utilities
* Redacts actual secret values rather than using pattern matching
* Secret detection and env filtering utilities
*/
import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names
export const SENSITIVE_PATTERNS = [
/_KEY$/i,
@@ -47,38 +44,3 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
// custom env object - merge with restricted base
return { ...filterEnv(), ...mode };
}
function getAllSecrets(): string[] {
const secrets: string[] = [];
// collect all env var values matching SENSITIVE_PATTERNS
for (const [key, value] of Object.entries(process.env)) {
if (value && isSensitiveEnvName(key)) {
secrets.push(value);
}
}
// add GitHub installation token (stored in memory, not in env)
try {
const token = getGitHubInstallationToken();
if (token) {
secrets.push(token);
}
} catch {
// token not set yet, ignore
}
return secrets;
}
export function redactSecrets(content: string, secrets?: string[]): string {
const secretsToRedact = [...(secrets ?? []), ...getAllSecrets()];
let redacted = content;
for (const secret of secretsToRedact) {
if (secret) {
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
redacted = redacted.replaceAll(new RegExp(escaped, "g"), "[REDACTED_SECRET]");
}
}
return redacted;
}