diff --git a/src/index.ts b/src/index.ts index 19d9730..ddf2045 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export * from "./ci.js"; export * from "./version.js"; export * from "./sidecar.js"; export * from "./egress-filter.js"; +export * from "./surrogate.js"; export * from "./top-monitor.js"; export * from "./mcp-wrapper.js"; diff --git a/src/surrogate.ts b/src/surrogate.ts new file mode 100644 index 0000000..b911d17 --- /dev/null +++ b/src/surrogate.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "node:crypto"; +import { isHighEntropySecret, isLikelyFalsePositive } from "./entropy.js"; +import { BUILTIN_PATTERNS } from "./patterns.js"; + +export interface SurrogateTokenMapping { + surrogate: string; + original: string; + category: string; + entropy: number; + createdAt: number; +} + +export interface SurrogateRedactionResult { + sanitized: string; + surrogatesCount: number; + mappings: SurrogateTokenMapping[]; +} + +/** + * Common high-risk credential regular expressions for deterministic AST token matching. + */ +const DETERMINISTIC_CREDENTIAL_PATTERNS: Array<{ + name: string; + regex: RegExp; +}> = [ + { name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/ }, + { name: "github_pat", regex: /\bghp_[a-zA-Z0-9]{36}\b/ }, + { name: "github_fine_grained", regex: /\bgithub_pat_[a-zA-Z0-9_]{82}\b/ }, + { name: "openai_api_key", regex: /\bsk-[a-zA-Z0-9]{48}\b/ }, + { name: "anthropic_api_key", regex: /\bsk-ant-[a-zA-Z0-9_\-]{80,}\b/ }, + { name: "bearer_token", regex: /\bBearer\s+([a-zA-Z0-9_\-\.]{24,})\b/i }, + { + name: "generic_api_key_flag", + regex: /(?:--api[-_]?key|--token|--secret)[=\s]+(["']?)([a-zA-Z0-9_\-\.]{16,})\1/i, + }, +]; + +/** + * In-Memory Volatile Surrogate Token Manager. + * Replaces high-risk secrets and high-entropy tokens with in-memory volatile decoy surrogates + * (`RD_SURROGATE_`) before child process invocation, ensuring that execution environments, + * memory dumps, error logs, and subshell wrappers never see or leak plaintext secrets. + */ +export class SurrogateManager { + private readonly forwardMap = new Map(); // surrogate -> mapping + private readonly reverseMap = new Map(); // original -> surrogate + private readonly prefix: string; + + constructor(prefix = "RD_SURROGATE") { + this.prefix = prefix; + } + + /** + * Generates or retrieves an existing volatile decoy surrogate token for a given secret. + */ + public getOrCreateSurrogate(original: string, category = "generic_secret", entropy = 0): string { + const existing = this.reverseMap.get(original); + if (existing) { + return existing; + } + + const surrogate = `${this.prefix}_${randomUUID().replace(/-/g, "").toUpperCase()}`; + const mapping: SurrogateTokenMapping = { + surrogate, + original, + category, + entropy, + createdAt: Date.now(), + }; + + this.forwardMap.set(surrogate, mapping); + this.reverseMap.set(original, surrogate); + return surrogate; + } + + /** + * Restores all surrogate tokens in a given string back to their original values. + */ + public restoreSurrogates(text: string): string { + if (!text || this.forwardMap.size === 0) return text; + + let restored = text; + for (const [surrogate, mapping] of this.forwardMap.entries()) { + restored = restored.replaceAll(surrogate, mapping.original); + } + return restored; + } + + /** + * Replaces all surrogate tokens with standard static redaction placeholders for public audit logging. + */ + public maskSurrogates(text: string, placeholder = "[REDACTED_SECRET]"): string { + if (!text || this.forwardMap.size === 0) return text; + + let masked = text; + for (const surrogate of this.forwardMap.keys()) { + masked = masked.replaceAll(surrogate, placeholder); + } + return masked; + } + + /** + * Retrieves all active in-memory mappings. + */ + public getMappings(): SurrogateTokenMapping[] { + return Array.from(this.forwardMap.values()); + } + + /** + * Returns whether a given string is a recognized volatile surrogate token. + */ + public isSurrogate(token: string): boolean { + return this.forwardMap.has(token); + } + + /** + * Volatile memory purge: destroys all surrogate mappings immediately. + */ + public clear(): void { + this.forwardMap.clear(); + this.reverseMap.clear(); + } + + /** + * Inspects a string, identifies credentials via deterministic patterns and entropy analysis, + * and substitutes them with volatile decoy surrogates. + */ + public redactWithSurrogates(content: string): SurrogateRedactionResult { + if (!content) { + return { sanitized: "", surrogatesCount: 0, mappings: [] }; + } + + let sanitized = content; + const detectedMappings: SurrogateTokenMapping[] = []; + + // 1. Check deterministic credential patterns + for (const pattern of DETERMINISTIC_CREDENTIAL_PATTERNS) { + sanitized = sanitized.replace(pattern.regex, (match, ...args) => { + // If first capture group is a string, use it; otherwise use the full match + const captured = typeof args[0] === "string" ? args[0] : match; + const target = captured || match; + if (typeof target !== "string" || isLikelyFalsePositive(target)) return match; + const surrogate = this.getOrCreateSurrogate(target, pattern.name, 5.0); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); + return match.replace(target, surrogate); + }); + } + + // 2. Check engine rule catalog patterns + for (const p of BUILTIN_PATTERNS) { + sanitized = sanitized.replace(p.pattern, (match) => { + if (isLikelyFalsePositive(match)) return match; + const surrogate = this.getOrCreateSurrogate(match, p.name, 4.8); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); + return surrogate; + }); + } + + // 3. Check standalone tokens for high Shannon entropy (H > 4.5) + const tokenRegex = /[^\s"':=;,|&><]+/g; + let tokenMatch: RegExpExecArray | null = tokenRegex.exec(content); + while (tokenMatch !== null) { + const candidate = tokenMatch[0]; + if ( + candidate.length >= 16 && + !candidate.startsWith(this.prefix) && + !isLikelyFalsePositive(candidate) + ) { + const { isSecret, entropy } = isHighEntropySecret(candidate, 4.5); + if (isSecret && !this.reverseMap.has(candidate)) { + const surrogate = this.getOrCreateSurrogate(candidate, "high_entropy_secret", entropy); + sanitized = sanitized.replaceAll(candidate, surrogate); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); + } + } + tokenMatch = tokenRegex.exec(content); + } + + return { + sanitized, + surrogatesCount: this.forwardMap.size, + mappings: Array.from(new Set(detectedMappings)), + }; + } +} diff --git a/tests/surrogate.test.ts b/tests/surrogate.test.ts new file mode 100644 index 0000000..754330b --- /dev/null +++ b/tests/surrogate.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { SurrogateManager } from "../src/surrogate.js"; + +describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { + it("replaces deterministic AWS access keys with RD_SURROGATE tokens", () => { + const manager = new SurrogateManager(); + const awsKey = ["AKIA", "IOSFODNN7EXAMPLE"].join(""); + const cmd = `curl -H 'Authorization: ${awsKey}' https://api.aws.com`; + const res = manager.redactWithSurrogates(cmd); + + assert.equal(res.surrogatesCount, 1); + assert.match(res.sanitized, /RD_SURROGATE_[0-9A-F]{32}/); + assert.doesNotMatch(res.sanitized, new RegExp(awsKey)); + + // Test restoration + const restored = manager.restoreSurrogates(res.sanitized); + assert.equal(restored, cmd); + }); + + it("replaces GitHub personal access tokens with surrogate decoys", () => { + const manager = new SurrogateManager(); + const token = ["ghp_", "mockTokenForTestingOnly12345678ABCD"].join(""); + const cmd = `git clone https://${token}@github.com/org/repo.git`; + const res = manager.redactWithSurrogates(cmd); + + assert.equal(res.surrogatesCount, 1); + assert.match(res.sanitized, /RD_SURROGATE_[0-9A-F]{32}/); + assert.doesNotMatch(res.sanitized, /mockTokenForTestingOnly/); + assert.equal(manager.restoreSurrogates(res.sanitized), cmd); + }); + + it("replaces OpenAI and Anthropic API keys with surrogates", () => { + const manager = new SurrogateManager(); + const openaiKey = ["sk-", "a".repeat(48)].join(""); + const cmd = `export OPENAI_API_KEY=${openaiKey}`; + const res = manager.redactWithSurrogates(cmd); + + assert.equal(res.surrogatesCount, 1); + assert.match(res.sanitized, /RD_SURROGATE_[0-9A-F]{32}/); + assert.doesNotMatch(res.sanitized, new RegExp(openaiKey)); + }); + + it("substitutes high-entropy strings (H > 4.5) while ignoring common false positives", () => { + const manager = new SurrogateManager(); + // High entropy random string constructed dynamically without diff literals + const secret = + Array.from({ length: 16 }, (_, i) => String.fromCharCode(65 + ((i * 17) % 26))).join("") + + Array.from({ length: 16 }, (_, i) => String.fromCharCode(97 + ((i * 13) % 26))).join(""); + const falsePositive = "00000000-0000-0000-0000-000000000000"; // UUID + const cmd = `node app.js --secret=${secret} --id=${falsePositive}`; + + const res = manager.redactWithSurrogates(cmd); + assert.match(res.sanitized, /RD_SURROGATE_/); + assert.match(res.sanitized, new RegExp(falsePositive)); // False positive preserved + assert.doesNotMatch(res.sanitized, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + }); + + it("masks surrogate tokens with standard static placeholder for public audit logging", () => { + const manager = new SurrogateManager(); + const awsKey = ["AKIA", "1234567890ABCDEF"].join(""); + const cmd = `export TOKEN=${awsKey}`; + const res = manager.redactWithSurrogates(cmd); + + const masked = manager.maskSurrogates(res.sanitized, "[MASKED]"); + assert.equal(masked, "export TOKEN=[MASKED]"); + }); + + it("purges all volatile surrogate mappings upon clear()", () => { + const manager = new SurrogateManager(); + const awsKey = ["AKIA", "1234567890ABCDEF"].join(""); + const res = manager.redactWithSurrogates(awsKey); + assert.equal(manager.getMappings().length, 1); + + manager.clear(); + assert.equal(manager.getMappings().length, 0); + // After clear, restoration leaves surrogates unchanged (no memory leak) + assert.equal(manager.restoreSurrogates(res.sanitized), res.sanitized); + }); +});