From eff404a2950a3844ed34d0925937ca443d08f371 Mon Sep 17 00:00:00 2001 From: "Steven Zimmerman, CPA" <15812269+EffortlessSteven@users.noreply.github.com> Date: Thu, 7 May 2026 10:24:14 -0400 Subject: [PATCH 1/2] security: stop uploading raw Droid runtime artifacts --- action.yml | 20 ------------------- test/action-yml.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 test/action-yml.test.ts diff --git a/action.yml b/action.yml index 6f0efcb..ce3bb21 100644 --- a/action.yml +++ b/action.yml @@ -353,23 +353,3 @@ runs: TRACK_PROGRESS: ${{ inputs.track_progress }} AUTOMATIC_REVIEW: ${{ inputs.automatic_review }} AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }} - - - name: Collect .factory debug files - if: always() && steps.prepare.outputs.contains_trigger == 'true' - shell: bash - run: | - if [ -d "$HOME/.factory" ]; then - cp -r "$HOME/.factory" "${{ runner.temp }}/.factory" - fi - - - name: Upload debug artifacts - if: always() && steps.prepare.outputs.contains_trigger == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: droid-review-debug-${{ github.run_id }} - path: | - ${{ runner.temp }}/.factory/** - ${{ runner.temp }}/droid-prompts/** - include-hidden-files: true - if-no-files-found: ignore - retention-days: 7 diff --git a/test/action-yml.test.ts b/test/action-yml.test.ts new file mode 100644 index 0000000..7f8f635 --- /dev/null +++ b/test/action-yml.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const actionYml = readFileSync( + join(import.meta.dir, "..", "action.yml"), + "utf8", +); + +describe("action.yml debug artifact invariants", () => { + it("loads the Droid composite action metadata", () => { + expect(actionYml.length).toBeGreaterThan(0); + expect(actionYml).toContain("runs:"); + expect(actionYml).toContain('using: "composite"'); + }); + + it("does not copy raw Factory runtime state", () => { + expect(actionYml).not.toContain("Collect .factory debug files"); + expect(actionYml).not.toMatch(/cp\s+-[rR]\s+["']?\$HOME\/?\.factory/); + }); + + it("does not upload raw prompt or Factory runtime trees directly", () => { + expect(actionYml).not.toContain("Upload debug artifacts"); + expect(actionYml).not.toMatch( + /\$\{\{\s*runner\.temp\s*\}\}\/\.factory(?:\/\*\*)?/, + ); + expect(actionYml).not.toMatch( + /\$\{\{\s*runner\.temp\s*\}\}\/droid-prompts\/\*\*/, + ); + expect(actionYml).not.toMatch( + /name:\s*droid-review-debug-\$\{\{\s*github\.run_id\s*\}/, + ); + }); + + it("does not enable hidden-file uploads for raw Droid runtime artifacts", () => { + expect(actionYml).not.toMatch( + /include-hidden-files:\s*true[\s\S]{0,500}(?:\.factory|droid-prompts)/, + ); + expect(actionYml).not.toMatch( + /(?:\.factory|droid-prompts)[\s\S]{0,500}include-hidden-files:\s*true/, + ); + }); +}); From 112c9795ed597b293cf85483a8e3723c409bbb87 Mon Sep 17 00:00:00 2001 From: "Steven Zimmerman, CPA" <15812269+EffortlessSteven@users.noreply.github.com> Date: Thu, 7 May 2026 10:24:45 -0400 Subject: [PATCH 2/2] debug: add redacted Droid debug artifacts --- README.md | 9 + action.yml | 35 ++ src/debug-artifacts/collect.ts | 219 +++++++++++ src/debug-artifacts/redact.ts | 121 +++++++ src/entrypoints/prepare-debug-artifacts.ts | 61 ++++ test/action-yml.test.ts | 12 + .../prepare-debug-artifacts.test.ts | 342 ++++++++++++++++++ test/debug-artifacts/redact.test.ts | 130 +++++++ 8 files changed, 929 insertions(+) create mode 100644 src/debug-artifacts/collect.ts create mode 100644 src/debug-artifacts/redact.ts create mode 100644 src/entrypoints/prepare-debug-artifacts.ts create mode 100644 test/debug-artifacts/prepare-debug-artifacts.test.ts create mode 100644 test/debug-artifacts/redact.test.ts diff --git a/README.md b/README.md index 7e19b78..35d98ae 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,15 @@ jobs: | `factory_api_key` | **Required.** Grants Droid Exec permission to run via Factory. | | `github_token` | Optional override if you prefer a custom GitHub App/token. By default the installed app token is used. | +### Debug Artifacts + +| Input | Default | Purpose | +| ------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `debug_artifacts` | `none` | Debug artifact upload mode. Set to `redacted` to upload a sanitized, allowlisted Droid debug bundle. | +| `debug_artifact_retention_days` | `1` | Retention days for sanitized debug artifacts. | + +Raw Droid runtime state is not uploaded. In `redacted` mode, the action builds a new allowlisted bundle under the runner temp directory and applies best-effort redaction before upload. + ### Review Configuration | Input | Default | Purpose | diff --git a/action.yml b/action.yml index ce3bb21..9171ccc 100644 --- a/action.yml +++ b/action.yml @@ -135,6 +135,14 @@ inputs: description: "Show full JSON output from Droid Exec. WARNING: This outputs ALL Droid messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments." required: false default: "false" + debug_artifacts: + description: "Debug artifact upload mode: none or redacted. Raw runtime state is not uploaded." + required: false + default: "none" + debug_artifact_retention_days: + description: "Retention days for sanitized debug artifacts." + required: false + default: "1" outputs: github_token: @@ -165,6 +173,14 @@ runs: cd ${GITHUB_ACTION_PATH} bun install + - name: Validate debug artifact mode + shell: bash + run: | + case "${{ inputs.debug_artifacts }}" in + none|redacted) ;; + *) echo "debug_artifacts must be one of: none, redacted" >&2; exit 1 ;; + esac + - name: Prepare action id: prepare shell: bash @@ -353,3 +369,22 @@ runs: TRACK_PROGRESS: ${{ inputs.track_progress }} AUTOMATIC_REVIEW: ${{ inputs.automatic_review }} AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }} + + - name: Prepare redacted debug artifacts + if: always() && steps.prepare.outputs.contains_trigger == 'true' && inputs.debug_artifacts == 'redacted' + shell: bash + run: | + bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare-debug-artifacts.ts + env: + DEBUG_ARTIFACTS_MODE: ${{ inputs.debug_artifacts }} + DEBUG_ARTIFACTS_DIR: ${{ runner.temp }}/droid-debug-artifacts + DROID_PROMPTS_DIR: ${{ runner.temp }}/droid-prompts + + - name: Upload redacted debug artifacts + if: always() && steps.prepare.outputs.contains_trigger == 'true' && inputs.debug_artifacts == 'redacted' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: droid-debug-${{ github.run_id }} + path: ${{ runner.temp }}/droid-debug-artifacts/** + if-no-files-found: error + retention-days: ${{ inputs.debug_artifact_retention_days }} diff --git a/src/debug-artifacts/collect.ts b/src/debug-artifacts/collect.ts new file mode 100644 index 0000000..32f6280 --- /dev/null +++ b/src/debug-artifacts/collect.ts @@ -0,0 +1,219 @@ +import { + mkdir, + lstat, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { redactFileText } from "./redact"; + +export type DebugArtifactMode = "none" | "redacted"; + +export interface PrepareDebugArtifactsOptions { + mode: DebugArtifactMode; + outputDir: string; + factoryHome: string; + droidPromptsDir: string; +} + +export interface PreparedDebugArtifacts { + prepared: boolean; + files: string[]; +} + +interface AllowlistedFile { + sourcePath: string; + outputPath: string; +} + +const PROMPT_FILES = [ + "droid-prompt.txt", + "pr.diff", + "existing_comments.json", + "pr_description.txt", + "review_candidates.json", + "review_validated.json", +]; + +const FACTORY_ROOT_FILES = ["settings.json", "settings.local.json", "mcp.json"]; +const MAX_RECURSIVE_FILES = 200; +const MAX_RECURSIVE_DEPTH = 5; + +function isEnoent(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT", + ); +} + +async function isDirectory(path: string): Promise { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch (error) { + if (isEnoent(error)) { + return false; + } + + throw error; + } +} + +async function isRegularFile(path: string): Promise { + try { + const info = await lstat(path); + return info.isFile(); + } catch (error) { + if (isEnoent(error)) { + return false; + } + + throw error; + } +} + +async function listFilesRecursive( + dir: string, + files: string[] = [], + depth = 0, +): Promise { + if (!(await isDirectory(dir))) return []; + if (depth > MAX_RECURSIVE_DEPTH || files.length >= MAX_RECURSIVE_FILES) { + return files; + } + + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + + const entryPath = join(dir, entry.name); + if (entry.isFile()) { + files.push(entryPath); + if (files.length >= MAX_RECURSIVE_FILES) break; + continue; + } + + if (entry.isDirectory()) { + await listFilesRecursive(entryPath, files, depth + 1); + } + } + + return files; +} + +function outputWithRedactedSuffix( + outputDir: string, + ...parts: string[] +): string { + const leaf = parts.at(-1); + if (!leaf) throw new Error("Cannot prepare output path without a filename"); + + return join(outputDir, ...parts.slice(0, -1), `${leaf}.redacted`); +} + +function relativePathParts(root: string, path: string): string[] { + return relative(root, path).split(/[\\/]/).filter(Boolean); +} + +async function buildAllowlist( + options: PrepareDebugArtifactsOptions, +): Promise { + const promptFiles = PROMPT_FILES.map((file) => ({ + sourcePath: join(options.droidPromptsDir, file), + outputPath: outputWithRedactedSuffix(options.outputDir, "prompts", file), + })); + + const factoryRootFiles = FACTORY_ROOT_FILES.map((file) => ({ + sourcePath: join(options.factoryHome, file), + outputPath: outputWithRedactedSuffix(options.outputDir, "factory", file), + })); + + const factoryDroidFiles = [ + { + sourcePath: join(options.factoryHome, "droid", "settings.json"), + outputPath: outputWithRedactedSuffix( + options.outputDir, + "factory", + "droid", + "settings.json", + ), + }, + ]; + + const logsRoot = join(options.factoryHome, "logs"); + const logFiles = (await listFilesRecursive(logsRoot)).map((file) => ({ + sourcePath: file, + outputPath: outputWithRedactedSuffix( + options.outputDir, + "factory", + "logs", + ...relativePathParts(logsRoot, file), + ), + })); + + const sessionsRoot = join(options.factoryHome, "sessions"); + const sessionFiles = (await listFilesRecursive(sessionsRoot)).map((file) => ({ + sourcePath: file, + outputPath: outputWithRedactedSuffix( + options.outputDir, + "factory", + "sessions", + ...relativePathParts(sessionsRoot, file), + ), + })); + + return [ + ...promptFiles, + ...factoryRootFiles, + ...factoryDroidFiles, + ...logFiles, + ...sessionFiles, + ]; +} + +export async function prepareDebugArtifacts( + options: PrepareDebugArtifactsOptions, +): Promise { + if (options.mode === "none") { + return { prepared: false, files: [] }; + } + + await rm(options.outputDir, { recursive: true, force: true }); + await mkdir(options.outputDir, { recursive: true }); + + const allowlist = await buildAllowlist(options); + const files: string[] = []; + + for (const file of allowlist) { + if (!(await isRegularFile(file.sourcePath))) continue; + + const input = await readFile(file.sourcePath, "utf8"); + const redacted = redactFileText(file.sourcePath, input); + + await mkdir(dirname(file.outputPath), { recursive: true }); + await writeFile(file.outputPath, redacted); + files.push(file.outputPath); + } + + const manifestPath = join(options.outputDir, "manifest.json"); + await writeFile( + manifestPath, + `${JSON.stringify( + { + mode: "redacted", + files: files + .map((file) => relative(options.outputDir, file).replace(/\\/g, "/")) + .sort(), + }, + null, + 2, + )}\n`, + ); + files.push(manifestPath); + + return { prepared: true, files }; +} diff --git a/src/debug-artifacts/redact.ts b/src/debug-artifacts/redact.ts new file mode 100644 index 0000000..2d0567a --- /dev/null +++ b/src/debug-artifacts/redact.ts @@ -0,0 +1,121 @@ +const REDACTED = "[REDACTED]"; + +const SENSITIVE_KEYS = new Set( + [ + "apiKey", + "api_key", + "token", + "secret", + "password", + "authorization", + "auth", + "credential", + "clientSecret", + "accessToken", + "refreshToken", + "FACTORY_API_KEY", + "CUSTOM_MODEL_API_KEY", + "PROVIDER_API_KEY", + "GITHUB_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + ].map((key) => key.toLowerCase()), +); + +const SENSITIVE_TEXT_KEY = + "(?:apiKey|api_key|token|secret|password|authorization|auth|credential|clientSecret|accessToken|refreshToken|FACTORY_API_KEY|CUSTOM_MODEL_API_KEY|PROVIDER_API_KEY|GITHUB_TOKEN|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY)"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase()); +} + +function looksHighEntropy(value: string): boolean { + if (value.length < 40) return false; + if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) return false; + return new Set(value).size >= 8; +} + +export function redactText(input: string): string { + let output = input; + + output = output.replace(/\b(?:ghs|ghp|gho|ghr)_[A-Za-z0-9_]+\b/g, REDACTED); + output = output.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, REDACTED); + output = output.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, REDACTED); + output = output.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, REDACTED); + + output = output.replace( + new RegExp("\\b(" + SENSITIVE_TEXT_KEY + "\\s*=\\s*)([^\\s\"';]+)", "gi"), + `$1${REDACTED}`, + ); + output = output.replace( + new RegExp(`\\b(${SENSITIVE_TEXT_KEY}\\s*:\\s*)([^\\r\\n,}]+)`, "gi"), + `$1${REDACTED}`, + ); + + output = output.replace( + /(? (looksHighEntropy(match) ? REDACTED : match), + ); + + return output; +} + +export function redactJsonValue(value: unknown, key?: string): unknown { + if (key && isSensitiveKey(key)) return REDACTED; + + if (typeof value === "string") { + return redactText(value); + } + + if (Array.isArray(value)) { + return value.map((item) => redactJsonValue(item)); + } + + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactJsonValue(entryValue, entryKey), + ]), + ); + } + + return value; +} + +export function redactJsonText(input: string): string { + try { + return `${JSON.stringify(redactJsonValue(JSON.parse(input)), null, 2)}\n`; + } catch { + return redactText(input); + } +} + +export function redactJsonlText(input: string): string { + const hasTrailingNewline = input.endsWith("\n"); + const lines = input.replace(/\r\n/g, "\n").split("\n"); + if (hasTrailingNewline) lines.pop(); + + const redacted = lines.map((line) => { + if (line.trim() === "") return line; + + try { + return JSON.stringify(redactJsonValue(JSON.parse(line))); + } catch { + return redactText(line); + } + }); + + return `${redacted.join("\n")}${hasTrailingNewline ? "\n" : ""}`; +} + +export function redactFileText(path: string, input: string): string { + if (path.endsWith(".jsonl")) return redactJsonlText(input); + if (path.endsWith(".json")) return redactJsonText(input); + return redactText(input); +} diff --git a/src/entrypoints/prepare-debug-artifacts.ts b/src/entrypoints/prepare-debug-artifacts.ts new file mode 100644 index 0000000..c3cd993 --- /dev/null +++ b/src/entrypoints/prepare-debug-artifacts.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env bun + +import * as core from "@actions/core"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + prepareDebugArtifacts, + type DebugArtifactMode, +} from "../debug-artifacts/collect"; + +function getMode(env: NodeJS.ProcessEnv): DebugArtifactMode { + const mode = (env.DEBUG_ARTIFACTS_MODE ?? env.DEBUG_ARTIFACTS ?? "redacted") + .trim() + .toLowerCase(); + + if (mode === "none" || mode === "redacted") return mode; + throw new Error("DEBUG_ARTIFACTS_MODE must be one of: none, redacted"); +} + +function requireEnv(env: NodeJS.ProcessEnv, name: string): string { + const value = env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +export async function prepareDebugArtifactsFromEnv( + env: NodeJS.ProcessEnv = process.env, +) { + const outputDir = requireEnv(env, "DEBUG_ARTIFACTS_DIR"); + const factoryHome = env.FACTORY_HOME ?? join(homedir(), ".factory"); + const droidPromptsDir = + env.DROID_PROMPTS_DIR ?? join(env.RUNNER_TEMP ?? "/tmp", "droid-prompts"); + + return prepareDebugArtifacts({ + mode: getMode(env), + outputDir, + factoryHome, + droidPromptsDir, + }); +} + +async function run() { + try { + const result = await prepareDebugArtifactsFromEnv(); + core.info( + result.prepared + ? `Prepared ${result.files.length} redacted debug artifact files` + : "Debug artifact mode is none; no bundle prepared", + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.setFailed(`Prepare debug artifacts failed: ${errorMessage}`); + process.exit(1); + } +} + +export default run; + +if (import.meta.main) { + run(); +} diff --git a/test/action-yml.test.ts b/test/action-yml.test.ts index 7f8f635..59e345e 100644 --- a/test/action-yml.test.ts +++ b/test/action-yml.test.ts @@ -40,4 +40,16 @@ describe("action.yml debug artifact invariants", () => { /(?:\.factory|droid-prompts)[\s\S]{0,500}include-hidden-files:\s*true/, ); }); + + it("keeps redacted upload-artifact usage SHA-pinned", () => { + expect(actionYml).toContain("Upload redacted debug artifacts"); + expect(actionYml).not.toContain("FACTORY_HOME: $HOME/.factory"); + expect(actionYml).toContain( + "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", + ); + expect(actionYml).toContain( + "path: ${{ runner.temp }}/droid-debug-artifacts/**", + ); + expect(actionYml).toContain("if-no-files-found: error"); + }); }); diff --git a/test/debug-artifacts/prepare-debug-artifacts.test.ts b/test/debug-artifacts/prepare-debug-artifacts.test.ts new file mode 100644 index 0000000..02e1220 --- /dev/null +++ b/test/debug-artifacts/prepare-debug-artifacts.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + mkdtemp, + mkdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prepareDebugArtifacts } from "../../src/debug-artifacts/collect"; + +const tempRoots: string[] = []; + +function fakeGitHubActionsToken(): string { + return ["ghs", "_", "a".repeat(36)].join(""); +} + +function fakeGitHubPat(): string { + return ["github", "pat", "test", "fake", "token"].join("_"); +} + +function fakeBearerHeader(): string { + return ["Bearer", ["test", "bearer", "token"].join("-")].join(" "); +} + +async function makeTempRoot() { + const root = await mkdtemp(join(tmpdir(), "droid-debug-artifacts-test-")); + tempRoots.push(root); + return root; +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + rm(root, { + recursive: true, + force: true, + }), + ), + ); +}); + +describe("prepareDebugArtifacts", () => { + it("debug_artifacts=none prepares no bundle", async () => { + const root = await makeTempRoot(); + const outputDir = join(root, "out"); + + const result = await prepareDebugArtifacts({ + mode: "none", + outputDir, + factoryHome: join(root, ".factory"), + droidPromptsDir: join(root, "droid-prompts"), + }); + + expect(result.prepared).toBe(false); + expect(result.files).toEqual([]); + expect(await exists(outputDir)).toBe(false); + }); + + it("debug_artifacts=redacted prepares only allowlisted redacted files", async () => { + const root = await makeTempRoot(); + const factoryHome = join(root, ".factory"); + const promptsDir = join(root, "droid-prompts"); + const outputDir = join(root, "out"); + const githubActionsToken = fakeGitHubActionsToken(); + const githubPatToken = fakeGitHubPat(); + const bearerHeader = fakeBearerHeader(); + const bearerValue = bearerHeader.replace("Bearer ", ""); + + await mkdir(join(factoryHome, "droid"), { recursive: true }); + await mkdir(join(factoryHome, "logs"), { recursive: true }); + await mkdir(join(factoryHome, "logs", "nested"), { recursive: true }); + await mkdir(join(factoryHome, "sessions"), { recursive: true }); + await mkdir(join(factoryHome, "sessions", "nested"), { recursive: true }); + await mkdir(join(factoryHome, "cache"), { recursive: true }); + await mkdir(join(factoryHome, "plugins", "plugin-a"), { recursive: true }); + await mkdir(join(factoryHome, "bin"), { recursive: true }); + await mkdir(promptsDir, { recursive: true }); + + await writeFile( + join(factoryHome, "settings.json"), + JSON.stringify({ + customModels: [{ apiKey: "custom-model-secret-test-value" }], + }), + ); + await writeFile( + join(factoryHome, "settings.local.json"), + JSON.stringify({ apiKey: "${CUSTOM_MODEL_API_KEY}" }), + ); + await writeFile( + join(factoryHome, "mcp.json"), + JSON.stringify({ + env: { GITHUB_TOKEN: githubActionsToken }, + }), + ); + await writeFile( + join(factoryHome, "droid", "settings.json"), + JSON.stringify({ + model: "test-model", + token: githubPatToken, + }), + ); + await writeFile( + join(factoryHome, "logs", "run.log"), + `Authorization: ${bearerHeader}`, + ); + await writeFile( + join(factoryHome, "logs", "nested", "nested.log"), + `nested Authorization: ${bearerHeader}`, + ); + await writeFile( + join(factoryHome, "sessions", "session.jsonl"), + `${JSON.stringify({ accessToken: githubPatToken })}\n`, + ); + await writeFile( + join(factoryHome, "sessions", "nested", "session.jsonl"), + `${JSON.stringify({ refreshToken: githubPatToken })}\n`, + ); + await writeFile(join(factoryHome, "cache", "raw.txt"), "do-not-copy"); + await writeFile( + join(factoryHome, "plugins", "plugin-a", "raw.txt"), + "do-not-copy", + ); + await writeFile(join(factoryHome, "bin", "droid"), "do-not-copy"); + await writeFile( + join(promptsDir, "droid-prompt.txt"), + "apiKey: custom-model-secret-test-value", + ); + await writeFile(join(promptsDir, "pr.diff"), bearerHeader); + await writeFile(join(promptsDir, "unknown.txt"), "do-not-copy"); + + const result = await prepareDebugArtifacts({ + mode: "redacted", + outputDir, + factoryHome, + droidPromptsDir: promptsDir, + }); + + expect(result.prepared).toBe(true); + expect(await exists(join(outputDir, "manifest.json"))).toBe(true); + expect(await exists(join(outputDir, "factory", "settings.json"))).toBe( + false, + ); + expect( + await exists(join(outputDir, "factory", "settings.json.redacted")), + ).toBe(true); + expect(await exists(join(outputDir, "factory", "mcp.json"))).toBe(false); + expect(await exists(join(outputDir, "factory", "mcp.json.redacted"))).toBe( + true, + ); + expect(await exists(join(outputDir, "factory", "cache"))).toBe(false); + expect(await exists(join(outputDir, "factory", "plugins"))).toBe(false); + expect(await exists(join(outputDir, "factory", "bin"))).toBe(false); + expect( + await exists(join(outputDir, "prompts", "unknown.txt.redacted")), + ).toBe(false); + + const redactedSettings = await readFile( + join(outputDir, "factory", "settings.json.redacted"), + "utf8", + ); + const redactedMcp = await readFile( + join(outputDir, "factory", "mcp.json.redacted"), + "utf8", + ); + const redactedPrompt = await readFile( + join(outputDir, "prompts", "droid-prompt.txt.redacted"), + "utf8", + ); + const redactedLog = await readFile( + join(outputDir, "factory", "logs", "run.log.redacted"), + "utf8", + ); + const redactedNestedLog = await readFile( + join(outputDir, "factory", "logs", "nested", "nested.log.redacted"), + "utf8", + ); + const redactedSession = await readFile( + join(outputDir, "factory", "sessions", "session.jsonl.redacted"), + "utf8", + ); + const redactedNestedSession = await readFile( + join( + outputDir, + "factory", + "sessions", + "nested", + "session.jsonl.redacted", + ), + "utf8", + ); + + const combined = [ + redactedSettings, + redactedMcp, + redactedPrompt, + redactedLog, + redactedNestedLog, + redactedSession, + redactedNestedSession, + ].join("\n"); + + expect(combined).not.toContain("custom-model-secret-test-value"); + expect(combined).not.toContain(githubActionsToken); + expect(combined).not.toContain(githubPatToken); + expect(combined).not.toContain(bearerValue); + + const manifest = JSON.parse( + await readFile(join(outputDir, "manifest.json"), "utf8"), + ) as { files: string[] }; + expect(manifest.files).toContain("factory/logs/run.log.redacted"); + expect(manifest.files).toContain("factory/logs/nested/nested.log.redacted"); + expect(manifest.files).toContain( + "factory/sessions/nested/session.jsonl.redacted", + ); + expect( + manifest.files.every( + (file) => !file.startsWith("/") && !file.includes("\\"), + ), + ).toBe(true); + }); + + it("removes stale output files before preparing a redacted bundle", async () => { + const root = await makeTempRoot(); + const factoryHome = join(root, ".factory"); + const promptsDir = join(root, "droid-prompts"); + const outputDir = join(root, "out"); + + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, "stale-secret.txt"), "do-not-keep"); + await mkdir(promptsDir, { recursive: true }); + await writeFile(join(promptsDir, "droid-prompt.txt"), "safe prompt"); + + const result = await prepareDebugArtifacts({ + mode: "redacted", + outputDir, + factoryHome, + droidPromptsDir: promptsDir, + }); + + expect(result.prepared).toBe(true); + expect(await exists(join(outputDir, "stale-secret.txt"))).toBe(false); + expect(await exists(join(outputDir, "manifest.json"))).toBe(true); + expect( + await exists(join(outputDir, "prompts", "droid-prompt.txt.redacted")), + ).toBe(true); + }); + + it("skips direct allowlisted files that are symlinks", async () => { + const root = await makeTempRoot(); + const factoryHome = join(root, ".factory"); + const promptsDir = join(root, "droid-prompts"); + const outputDir = join(root, "out"); + const outsideFile = join(root, "outside-settings.json"); + + await mkdir(factoryHome, { recursive: true }); + await mkdir(promptsDir, { recursive: true }); + await writeFile(outsideFile, "custom-model-secret-test-value"); + + try { + await symlink(outsideFile, join(factoryHome, "settings.json"), "file"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "EPERM" + ) { + return; + } + + throw error; + } + + const result = await prepareDebugArtifacts({ + mode: "redacted", + outputDir, + factoryHome, + droidPromptsDir: promptsDir, + }); + + expect(result.prepared).toBe(true); + expect( + await exists(join(outputDir, "factory", "settings.json.redacted")), + ).toBe(false); + }); + + it("skips recursive allowlisted directories that are symlinks", async () => { + const root = await makeTempRoot(); + const factoryHome = join(root, ".factory"); + const promptsDir = join(root, "droid-prompts"); + const outputDir = join(root, "out"); + const outsideLogs = join(root, "outside-logs"); + + await mkdir(factoryHome, { recursive: true }); + await mkdir(promptsDir, { recursive: true }); + await mkdir(outsideLogs, { recursive: true }); + await writeFile( + join(outsideLogs, "run.log"), + "custom-model-secret-test-value", + ); + + try { + await symlink(outsideLogs, join(factoryHome, "logs"), "dir"); + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "EPERM" + ) { + return; + } + + throw error; + } + + const result = await prepareDebugArtifacts({ + mode: "redacted", + outputDir, + factoryHome, + droidPromptsDir: promptsDir, + }); + + expect(result.prepared).toBe(true); + expect( + await exists(join(outputDir, "factory", "logs", "run.log.redacted")), + ).toBe(false); + }); +}); diff --git a/test/debug-artifacts/redact.test.ts b/test/debug-artifacts/redact.test.ts new file mode 100644 index 0000000..517ece8 --- /dev/null +++ b/test/debug-artifacts/redact.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import { + redactJsonText, + redactJsonlText, + redactText, +} from "../../src/debug-artifacts/redact"; + +function fakeGitHubToken(prefix = "ghs"): string { + return [prefix, "_", "a".repeat(36)].join(""); +} + +function fakeGitHubPat(): string { + return ["github", "pat", "test", "fake", "token"].join("_"); +} + +function fakeBearerHeader(): string { + return ["Bearer", ["test", "bearer", "token"].join("-")].join(" "); +} + +describe("debug artifact redaction", () => { + it("redacts apiKey recursively in JSON", () => { + const redacted = redactJsonText( + JSON.stringify({ + customModels: [{ apiKey: "custom-model-secret-test-value" }], + }), + ); + + expect(redacted).not.toContain("custom-model-secret-test-value"); + expect(JSON.parse(redacted).customModels[0].apiKey).toBe("[REDACTED]"); + }); + + it("redacts api_key recursively in JSON", () => { + const redacted = redactJsonText( + JSON.stringify({ nested: { api_key: "openai-secret-test-value" } }), + ); + + expect(redacted).not.toContain("openai-secret-test-value"); + expect(JSON.parse(redacted).nested.api_key).toBe("[REDACTED]"); + }); + + it("redacts token/auth/secret/password keys case-insensitively", () => { + const redacted = JSON.parse( + redactJsonText( + JSON.stringify({ + Token: "token-value", + AUTH: "auth-value", + Secret: "secret-value", + password: "password-value", + }), + ), + ); + + expect(redacted.Token).toBe("[REDACTED]"); + expect(redacted.AUTH).toBe("[REDACTED]"); + expect(redacted.Secret).toBe("[REDACTED]"); + expect(redacted.password).toBe("[REDACTED]"); + }); + + it("redacts GitHub tokens in MCP-shaped JSON", () => { + const githubActionsToken = fakeGitHubToken(); + const redacted = redactJsonText( + JSON.stringify({ + mcpServers: { + github: { + env: { + GITHUB_TOKEN: githubActionsToken, + }, + }, + }, + }), + ); + + expect(redacted).not.toContain(githubActionsToken); + expect(JSON.parse(redacted).mcpServers.github.env.GITHUB_TOKEN).toBe( + "[REDACTED]", + ); + }); + + it("redacts Bearer tokens in logs", () => { + const bearerHeader = fakeBearerHeader(); + const bearerValue = bearerHeader.replace("Bearer ", ""); + const redacted = redactText(`Authorization: ${bearerHeader}\nnext line`); + + expect(redacted).not.toContain(bearerValue); + expect(redacted).toContain("Authorization: [REDACTED]"); + }); + + it("redacts env assignment strings", () => { + const githubActionsToken = fakeGitHubToken(); + const redacted = redactText( + `GITHUB_TOKEN=${githubActionsToken} --env TOKEN=secret-value`, + ); + + expect(redacted).not.toContain(githubActionsToken); + expect(redacted).not.toContain("secret-value"); + expect(redacted).toContain("GITHUB_TOKEN=[REDACTED]"); + expect(redacted).toContain("TOKEN=[REDACTED]"); + }); + + it("redacts high-entropy Base64-like values with symbol edges", () => { + const token = `+${"Ab3/=".repeat(8)}Z9=`; + const redacted = redactText(`payload ${token} done`); + + expect(redacted).not.toContain(token); + expect(redacted).toContain("payload [REDACTED] done"); + }); + + it("redacts JSONL line by line", () => { + const githubPatToken = fakeGitHubPat(); + const bearerHeader = fakeBearerHeader(); + const bearerValue = bearerHeader.replace("Bearer ", ""); + const redacted = redactJsonlText( + `${JSON.stringify({ token: githubPatToken })}\nnot json Authorization: ${bearerHeader}\n`, + ); + + expect(redacted).not.toContain(githubPatToken); + expect(redacted).not.toContain(bearerValue); + expect(redacted.split("\n")[0]).toBe('{"token":"[REDACTED]"}'); + }); + + it("falls back to text redaction for invalid JSON", () => { + const githubActionsToken = fakeGitHubToken(); + const redacted = redactJsonText( + `{ invalid json GITHUB_TOKEN=${githubActionsToken}`, + ); + + expect(redacted).not.toContain(githubActionsToken); + expect(redacted).toContain("GITHUB_TOKEN=[REDACTED]"); + }); +});