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 6f0efcb..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 @@ -354,22 +370,21 @@ runs: 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' + - name: Prepare redacted debug artifacts + if: always() && steps.prepare.outputs.contains_trigger == 'true' && inputs.debug_artifacts == 'redacted' shell: bash run: | - if [ -d "$HOME/.factory" ]; then - cp -r "$HOME/.factory" "${{ runner.temp }}/.factory" - fi + 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 debug artifacts - if: always() && steps.prepare.outputs.contains_trigger == 'true' + - 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-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 + 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 new file mode 100644 index 0000000..59e345e --- /dev/null +++ b/test/action-yml.test.ts @@ -0,0 +1,55 @@ +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/, + ); + }); + + 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]"); + }); +});