fix: harden deploy gate against token exfil and forged receipts

This commit is contained in:
artale 2026-06-23 12:28:03 +02:00
parent fecd8e10d2
commit b076a9525a
2 changed files with 62 additions and 13 deletions

View File

@ -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);
});
});

View File

@ -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;
}