adhoc: push:restricted adversarial pentest (#827)

* adhoc: push:restricted adversarial pentest

enumerates the 16 attack vectors the deep audit identified as load-bearing
for `push: restricted`. used to drive e2e verification against the preview
repo's pullfrog.yml; also runnable via pnpm runtest locally.

validator only asserts that the repo's default branch SHA didn't move —
the per-attack outputs are the deliverable for human review (the test
exists to feed adversarial runs, not to be a CI guard).

* wipe runner leak surface before agent spawn

the GHA runner persists credentials inside $RUNNER_TEMP that an MCP-shell
agent can grep — _runner_file_commands/set_output_* (from any composite
step that called core.setOutput, e.g. pullfrog/pullfrog/get-installation-token
which leaks a ghs_… installation token), <uuid>.sh rendered step scripts
(whose run: | body embeds ${{ ... }} expressions literally before write),
and git-credentials-*.config from actions/checkout@v6.

snapshot-and-delete that surface at action startup, after our own token is
in memory and before setupGit. preserves $GITHUB_OUTPUT, $GITHUB_ENV, and
$GITHUB_STATE so pullfrog's result output and post: hook still work.

setupGit's existing removeIncludeIfEntries call strips the matching
dangling includeIf.gitdir:....path entries from the user's .git/config.

does not tighten isGitCommand — that's a UX guard, not a security
boundary, and trivially bypassable via bash -c, absolute paths, symlinks,
python subprocess. the security boundary is the absence of credentials on
disk for those bypassed shells to authenticate with.

verified end-to-end by re-firing action/test/adhoc/pushRestrictedAdversarial
against pullfrog/preview-827-push-restricted-pentest.

* preserve all runner file-command paths from wipe

addresses pullfrog review on f7f5143b: GITHUB_STEP_SUMMARY also lives at
$RUNNER_TEMP/_runner_file_commands/step_summary_<uuid> and is read by the
runner AFTER our step exits to render the job summary in the GH UI. wiping
it silently broke pullfrog's job summary output. preserve GITHUB_PATH too
for symmetry — it's the same allocation pattern, and a step or post hook
that appends a directory expects the file to exist.

set of file-command env vars enumerated in @actions/core:
  GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STATE, GITHUB_STEP_SUMMARY
This commit is contained in:
Colin McDonnell
2026-05-23 00:35:43 +00:00
committed by pullfrog[bot]
parent b6c57547ca
commit fd2c67ab50
3 changed files with 263 additions and 2 deletions
+91 -1
View File
@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readdirSync, realpathSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ShellPermission } from "../external.ts";
@@ -23,6 +23,96 @@ export function createTempDirectory(): string {
return sharedTempDir;
}
/**
* snapshot-and-delete the GHA runner's known credential leak surfaces inside
* `$RUNNER_TEMP` before the agent spawns. without this, a shell-capable agent
* can grep:
* - `_runner_file_commands/set_output_*` for `core.setOutput('token', ghs_…)`
* calls made by earlier composite-action steps (e.g.
* pullfrog/pullfrog/get-installation-token);
* - `<uuid>.sh` rendered step scripts whose `run: |` body embeds
* `${{ steps.token.outputs.token }}` literally (GHA expands BEFORE writing);
* - `git-credentials-*.config` written by `actions/checkout@v6` for the
* workflow GITHUB_TOKEN.
*
* the running bash process already has its own `.sh` open via fd, so the
* unlink is safe — `unlink` removes the dirent, the kernel keeps reading.
*
* preserves every `_runner_file_commands/` file path the runner pre-allocated
* for OUR step — `$GITHUB_OUTPUT`, `$GITHUB_ENV`, `$GITHUB_PATH`,
* `$GITHUB_STATE`, `$GITHUB_STEP_SUMMARY`. those are read by the runner
* AFTER we exit (or by our own `post:` hook), and wiping them would break
* pullfrog's `result` output, `post:` state handoff, and job summary.
*
* silent no-op when `$RUNNER_TEMP` is unset (local dev, `pnpm play`).
* per-file errors are tolerated — the runner may delete files between
* our readdir and our unlink.
*/
export function wipeRunnerLeakSurface(): void {
const runnerTemp = process.env.RUNNER_TEMP;
if (!runnerTemp) return;
const preserve = new Set<string>();
for (const envVar of [
"GITHUB_OUTPUT",
"GITHUB_ENV",
"GITHUB_PATH",
"GITHUB_STATE",
"GITHUB_STEP_SUMMARY",
]) {
const path = process.env[envVar];
if (!path) continue;
try {
preserve.add(realpathSync(path));
} catch {
// path may not exist yet — preserve the literal in case it gets created later
preserve.add(path);
}
}
const wiped: string[] = [];
const tryUnlink = (path: string): void => {
let resolved = path;
try {
resolved = realpathSync(path);
} catch {
// file may already be gone — fall through to unlink for the race-tolerant path
}
if (preserve.has(resolved) || preserve.has(path)) return;
try {
unlinkSync(path);
wiped.push(path);
} catch {
// race-tolerant: file may have been deleted between readdir and unlink
}
};
const listDir = (dir: string): string[] => {
try {
return readdirSync(dir);
} catch {
return [];
}
};
const fileCommandsDir = join(runnerTemp, "_runner_file_commands");
for (const entry of listDir(fileCommandsDir)) {
tryUnlink(join(fileCommandsDir, entry));
}
for (const entry of listDir(runnerTemp)) {
if (entry.endsWith(".sh") || /^git-credentials-.*\.config$/.test(entry)) {
tryUnlink(join(runnerTemp, entry));
}
}
if (wiped.length > 0) {
log.info(`» wiped ${wiped.length} leak-surface file(s) from $RUNNER_TEMP`);
log.debug(`» wiped paths: ${wiped.join(", ")}`);
}
}
/**
* Setup the test repository for running actions
*/