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:
committed by
pullfrog[bot]
parent
b6c57547ca
commit
fd2c67ab50
+91
-1
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user