fix: harden deploy gate against token exfil and forged receipts
This commit is contained in:
parent
fecd8e10d2
commit
b076a9525a
|
|
@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { deployToFactory, hasPassedGateReceipt, isAllowedFactoryCommand } from "./factory-deploy.js";
|
||||
import { deployToFactory, hasPassedGateReceipt, isAllowedDeployUrl, isAllowedFactoryCommand } from "./factory-deploy.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
|
|
@ -18,7 +18,13 @@ function tmpFile(name = "receipt.json"): string {
|
|||
|
||||
function passedGateReceipt(): string {
|
||||
const file = tmpFile("zte-receipts.jsonl");
|
||||
fs.writeFileSync(file, `${JSON.stringify({ status: "passed", task: "gate" })}\n`);
|
||||
fs.writeFileSync(file, `${JSON.stringify({
|
||||
status: "passed",
|
||||
task: "gate",
|
||||
repo: ".",
|
||||
commands: ["fable-agent fable5 verify repo gate --repo ."],
|
||||
createdAt: "2026-06-23T00:00:00.000Z",
|
||||
})}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +37,7 @@ describe("factory deploy", () => {
|
|||
expect(receipt.reason).toContain("--yes");
|
||||
});
|
||||
|
||||
it("blocks without a passed gate receipt", async () => {
|
||||
it("blocks without a passed verifier receipt", async () => {
|
||||
const receipt = await deployToFactory({ command: "echo ok", token: "token", yes: true });
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
|
|
@ -39,12 +45,34 @@ describe("factory deploy", () => {
|
|||
expect(receipt.reason).toContain("gate-receipt");
|
||||
});
|
||||
|
||||
it("blocks arbitrary deploy URLs before sending the token", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const receipt = await deployToFactory({
|
||||
command: "echo ok",
|
||||
token: "token",
|
||||
yes: true,
|
||||
gateReceipt: passedGateReceipt(),
|
||||
url: "https://attacker.test/deploy",
|
||||
fetcher: async (_url, init) => {
|
||||
calls.push(init);
|
||||
return { status: 200, json: async () => ({}), text: async () => "" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
expect(receipt.deploy_attempted).toBe(false);
|
||||
expect(receipt.reason).toContain("guarded 8099");
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(isAllowedDeployUrl("https://attacker.test/deploy")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks unsafe command prefixes", async () => {
|
||||
const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true, gateReceipt: passedGateReceipt() });
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
expect(receipt.reason).toContain("command must start");
|
||||
expect(receipt.reason).toContain("allowlist");
|
||||
expect(isAllowedFactoryCommand("docker ps")).toBe(true);
|
||||
expect(isAllowedFactoryCommand("docker ps; rm -rf /")).toBe(false);
|
||||
expect(isAllowedFactoryCommand("rm -rf /")).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -100,8 +128,11 @@ describe("factory deploy", () => {
|
|||
expect(fs.readFileSync(out, "utf-8")).toContain("network down");
|
||||
});
|
||||
|
||||
it("detects passed JSONL gate receipts", () => {
|
||||
it("detects passed JSONL verifier receipts only", () => {
|
||||
expect(hasPassedGateReceipt(passedGateReceipt())).toBe(true);
|
||||
const forged = tmpFile("forged.jsonl");
|
||||
fs.writeFileSync(forged, `${JSON.stringify({ status: "passed", task: "gate" })}\n`);
|
||||
expect(hasPassedGateReceipt(forged)).toBe(false);
|
||||
expect(hasPassedGateReceipt(tmpFile("missing.jsonl"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,11 +25,18 @@ export interface FactoryDeployReceipt {
|
|||
}
|
||||
|
||||
const DEFAULT_URL = "http://77.42.112.29:8099/deploy";
|
||||
const ALLOWED_PREFIXES = ["docker ", "echo ", "curl ", "cat "];
|
||||
const SAFE_COMMANDS = [
|
||||
/^echo [A-Za-z0-9 _.,:;@/+\-=]+$/,
|
||||
/^cat \/tmp\/[A-Za-z0-9._/-]+$/,
|
||||
/^docker ps(?: --format '[A-Za-z0-9 {}_.:;@/+\-=|]+')?$/,
|
||||
/^docker logs --tail \d{1,4} [A-Za-z0-9_.-]+$/,
|
||||
/^docker inspect [A-Za-z0-9_.-]+$/,
|
||||
/^curl -fsS -m \d{1,2} http:\/\/127\.0\.0\.1:8099\/(?:health|factory-status)$/,
|
||||
];
|
||||
|
||||
export async function deployToFactory(opts: FactoryDeployOptions): Promise<FactoryDeployReceipt> {
|
||||
const url = opts.url ?? DEFAULT_URL;
|
||||
const blocked = blockReason(opts);
|
||||
const blocked = blockReason(opts, url);
|
||||
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
|
||||
|
||||
try {
|
||||
|
|
@ -52,18 +59,28 @@ export async function deployToFactory(opts: FactoryDeployOptions): Promise<Facto
|
|||
}
|
||||
|
||||
export function isAllowedFactoryCommand(command: string): boolean {
|
||||
return ALLOWED_PREFIXES.some((prefix) => command.trim().startsWith(prefix));
|
||||
return SAFE_COMMANDS.some((pattern) => pattern.test(command.trim()));
|
||||
}
|
||||
|
||||
export function isAllowedDeployUrl(url: string): boolean {
|
||||
return url === DEFAULT_URL;
|
||||
}
|
||||
|
||||
export function hasPassedGateReceipt(file: string): boolean {
|
||||
if (!fs.existsSync(file)) return false;
|
||||
// ponytail: accept JSON or JSONL; ZTE currently appends JSONL receipts.
|
||||
return fs.readFileSync(file, "utf-8")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.some((line) => {
|
||||
try {
|
||||
return JSON.parse(line).status === "passed";
|
||||
const json = JSON.parse(line) as { status?: unknown; task?: unknown; repo?: unknown; commands?: unknown; createdAt?: unknown };
|
||||
return json.status === "passed"
|
||||
&& typeof json.task === "string"
|
||||
&& typeof json.repo === "string"
|
||||
&& Array.isArray(json.commands)
|
||||
&& json.commands.some((cmd) => typeof cmd === "string" && cmd.includes("fable5 verify"))
|
||||
&& typeof json.createdAt === "string"
|
||||
&& Number.isFinite(Date.parse(json.createdAt));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -76,12 +93,13 @@ export function writeFactoryDeployReceipt(file: string, value: FactoryDeployRece
|
|||
return value;
|
||||
}
|
||||
|
||||
function blockReason(opts: FactoryDeployOptions): string | undefined {
|
||||
function blockReason(opts: FactoryDeployOptions, url: string): string | undefined {
|
||||
if (!opts.yes) return "missing --yes human approval";
|
||||
if (!isAllowedDeployUrl(url)) return "deploy url must be the guarded 8099 git-proxy route";
|
||||
if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
|
||||
if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed status";
|
||||
if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed verifier status";
|
||||
if (!opts.token) return "missing deploy token";
|
||||
if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`;
|
||||
if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue