feat: add cyber preflight receipts
This commit is contained in:
parent
419881acbd
commit
afc349ee59
|
|
@ -27,6 +27,7 @@ fable-agent plinius godmode "improve explanation quality"
|
||||||
| `skills` | Skill registry ops |
|
| `skills` | Skill registry ops |
|
||||||
| `state` | Knowledge state / stats view |
|
| `state` | Knowledge state / stats view |
|
||||||
| `security` | Security scanners |
|
| `security` | Security scanners |
|
||||||
|
| `cyber` | Read-only defensive cyber evidence collection |
|
||||||
| `pai` | PAI/bridge operations |
|
| `pai` | PAI/bridge operations |
|
||||||
| `status` | Runtime surface summary |
|
| `status` | Runtime surface summary |
|
||||||
| `workflow` | Load workflow JSON |
|
| `workflow` | Load workflow JSON |
|
||||||
|
|
@ -93,6 +94,14 @@ fable-agent plinius godmode "improve explanation quality"
|
||||||
- `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers
|
- `security scan <target>`: hidden Unicode, reversed tags, fake wrappers, role spoofing, and instruction-smuggling markers
|
||||||
- `--include-fixtures`: include intentional red-team fixtures/generators
|
- `--include-fixtures`: include intentional red-team fixtures/generators
|
||||||
|
|
||||||
|
### `cyber`
|
||||||
|
|
||||||
|
- `cyber preflight`
|
||||||
|
- `--host <host>`
|
||||||
|
- `--port <n>`
|
||||||
|
- `--mode <mode>`
|
||||||
|
- `--output <file>`
|
||||||
|
|
||||||
### `pai`
|
### `pai`
|
||||||
|
|
||||||
- `pai status`
|
- `pai status`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
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 { runCyberPreflight } from "./cyber-preflight.js";
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function tmpFile(): string {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-cyber-preflight-"));
|
||||||
|
roots.push(root);
|
||||||
|
return path.join(root, "receipt.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runner(failNames: string[] = []) {
|
||||||
|
return (_command: string, args: string[]) => {
|
||||||
|
const joined = args.join(" ");
|
||||||
|
const shouldFail = failNames.some((name) => joined.includes(name));
|
||||||
|
return {
|
||||||
|
status: shouldFail ? 22 : 0,
|
||||||
|
stdout: shouldFail ? "" : "ok token=SHOULD_REDACT",
|
||||||
|
stderr: shouldFail ? "curl: returned 404 Authorization: token SHOULD_REDACT" : "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cyber preflight", () => {
|
||||||
|
it("allows read-only when required live checks pass", () => {
|
||||||
|
const receipt = runCyberPreflight({ host: "127.0.0.1", runner: runner(), now: new Date("2026-06-16T00:00:00.000Z") });
|
||||||
|
|
||||||
|
expect(receipt.decision).toBe("allow_read_only");
|
||||||
|
expect(receipt.deploy_allowed).toBe(false);
|
||||||
|
expect(receipt.checks).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when a required endpoint is missing", () => {
|
||||||
|
const receipt = runCyberPreflight({ host: "127.0.0.1", runner: runner(["factory-status"]) });
|
||||||
|
|
||||||
|
expect(receipt.decision).toBe("fail_closed");
|
||||||
|
expect(receipt.deploy_allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a redacted receipt", () => {
|
||||||
|
const out = tmpFile();
|
||||||
|
runCyberPreflight({ host: "127.0.0.1", out, runner: runner(["health"]) });
|
||||||
|
|
||||||
|
const text = fs.readFileSync(out, "utf-8");
|
||||||
|
expect(text).toContain("[REDACTED]");
|
||||||
|
expect(text).not.toContain("SHOULD_REDACT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
export interface CyberPreflightCheck {
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
ok: boolean;
|
||||||
|
status: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CyberPreflightReceipt {
|
||||||
|
created_at: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
mode: "read-only";
|
||||||
|
checks: CyberPreflightCheck[];
|
||||||
|
decision: "allow_read_only" | "fail_closed";
|
||||||
|
deploy_allowed: false;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CyberPreflightOptions {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
out?: string;
|
||||||
|
now?: Date;
|
||||||
|
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_ENDPOINTS = [
|
||||||
|
["git_proxy_health", "http://127.0.0.1:8099/health"],
|
||||||
|
["git_proxy_factory_status", "http://127.0.0.1:8099/factory-status"],
|
||||||
|
["deploy_webhook_health", "http://127.0.0.1:8098/health"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function runCyberPreflight(opts: CyberPreflightOptions): CyberPreflightReceipt {
|
||||||
|
const port = opts.port ?? 2222;
|
||||||
|
const runner = opts.runner ?? runCommand;
|
||||||
|
const checks: CyberPreflightCheck[] = [];
|
||||||
|
|
||||||
|
checks.push(runCheck("ssh_reachable", "ssh", ["-p", String(port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", `root@${opts.host}`, "hostname"], runner));
|
||||||
|
checks.push(runCheck("docker_inventory", "ssh", ["-p", String(port), `root@${opts.host}`, "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"], runner));
|
||||||
|
|
||||||
|
for (const [name, url] of REQUIRED_ENDPOINTS) {
|
||||||
|
checks.push(runCheck(name, "ssh", ["-p", String(port), `root@${opts.host}`, `curl -fsS -m 5 ${url}`], runner));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredOk = checks.filter((c) => c.name !== "docker_inventory").every((c) => c.ok);
|
||||||
|
const receipt: CyberPreflightReceipt = {
|
||||||
|
created_at: (opts.now ?? new Date()).toISOString(),
|
||||||
|
host: opts.host,
|
||||||
|
port,
|
||||||
|
mode: "read-only",
|
||||||
|
checks,
|
||||||
|
decision: requiredOk ? "allow_read_only" : "fail_closed",
|
||||||
|
deploy_allowed: false,
|
||||||
|
reason: requiredOk ? "read-only evidence checks passed" : "required live evidence missing or unhealthy",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.out) writeCyberPreflightReceipt(opts.out, receipt);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCyberPreflightReceipt(file: string, receipt: CyberPreflightReceipt): void {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCheck(name: string, command: string, args: string[], runner: NonNullable<CyberPreflightOptions["runner"]>): CyberPreflightCheck {
|
||||||
|
const result = runner(command, args);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
command: [command, ...args].join(" "),
|
||||||
|
ok: result.status === 0,
|
||||||
|
status: result.status,
|
||||||
|
stdout: redact(result.stdout).slice(0, 4000),
|
||||||
|
stderr: redact(result.stderr).slice(0, 4000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||||
|
const result = spawnSync(command, args, { encoding: "utf-8" });
|
||||||
|
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(Authorization:\s*(?:Bearer|token)\s+)[^\s]+/gi, "$1[REDACTED]")
|
||||||
|
.replace(/((?:token|secret|password|api[_-]?key)=)[^\s]+/gi, "$1[REDACTED]");
|
||||||
|
}
|
||||||
|
|
@ -53,5 +53,8 @@ export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";
|
||||||
export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js";
|
export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js";
|
||||||
export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js";
|
export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js";
|
||||||
|
|
||||||
|
export { runCyberPreflight, writeCyberPreflightReceipt } from "./cyber-preflight.js";
|
||||||
|
export type { CyberPreflightCheck, CyberPreflightOptions, CyberPreflightReceipt } from "./cyber-preflight.js";
|
||||||
|
|
||||||
export { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js";
|
export { createForgejoIntakeReceipt, intakeForgejoItem, parseForgejoRef, routeForgejoLabels } from "./forgejo-intake.js";
|
||||||
export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js";
|
export type { ForgejoIntakeKind, ForgejoIntakeOptions, ForgejoIntakeReceipt, ForgejoIntakeSource, ForgejoItem, ForgejoRoute } from "./forgejo-intake.js";
|
||||||
|
|
|
||||||
26
src/index.ts
26
src/index.ts
|
|
@ -2802,6 +2802,32 @@ const security = program
|
||||||
.command("security")
|
.command("security")
|
||||||
.description("Security scanners");
|
.description("Security scanners");
|
||||||
|
|
||||||
|
// ── Cyber ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cyber = program
|
||||||
|
.command("cyber")
|
||||||
|
.description("Read-only defensive cyber evidence collection");
|
||||||
|
|
||||||
|
cyber
|
||||||
|
.command("preflight")
|
||||||
|
.description("Collect read-only factory evidence and write a fail-closed receipt")
|
||||||
|
.requiredOption("--host <host>", "Target host to inspect over SSH")
|
||||||
|
.option("--port <n>", "SSH port", (v) => Number(v), 2222)
|
||||||
|
.option("--mode <mode>", "Only read-only is supported", "read-only")
|
||||||
|
.option("--output <file>", "Receipt output file")
|
||||||
|
.action(async (opts: { host: string; port?: number; mode?: string; output?: string }) => {
|
||||||
|
if (opts.mode !== "read-only") {
|
||||||
|
console.error(" ✗ cyber preflight only supports --mode read-only");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const { runCyberPreflight } = await import("./fable5/cyber-preflight.js");
|
||||||
|
const out = opts.output ?? path.join(".fable", "cyber-preflight", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
||||||
|
const receipt = runCyberPreflight({ host: opts.host, port: opts.port, out });
|
||||||
|
console.log(JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(`\n Receipt: ${out}\n`);
|
||||||
|
if (receipt.decision !== "allow_read_only") process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
security
|
security
|
||||||
.command("scan <target>")
|
.command("scan <target>")
|
||||||
.description("Scan files for prompt-injection markers")
|
.description("Scan files for prompt-injection markers")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue