From b2159abbeba0cd03444282c18d04984effcec1ea Mon Sep 17 00:00:00 2001 From: divyanshujethi Date: Sun, 8 Nov 2026 20:51:05 +0530 Subject: [PATCH 1/5] feat(surrogate): implement in-memory volatile decoy surrogate redactor (Phase 1) --- src/index.ts | 1 + src/surrogate.ts | 185 ++++++++++++++++++++++++++++++++++++++++ tests/surrogate.test.ts | 75 ++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/surrogate.ts create mode 100644 tests/surrogate.test.ts 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..247a21c --- /dev/null +++ b/src/surrogate.ts @@ -0,0 +1,185 @@ +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 { + if (this.reverseMap.has(original)) { + return this.reverseMap.get(original)!; + } + + 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); + detectedMappings.push(this.forwardMap.get(surrogate)!); + 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); + detectedMappings.push(this.forwardMap.get(surrogate)!); + 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); + detectedMappings.push(this.forwardMap.get(surrogate)!); + } + } + 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..6a72df7 --- /dev/null +++ b/tests/surrogate.test.ts @@ -0,0 +1,75 @@ +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 cmd = "curl -H 'Authorization: AKIAIOSFODNN7EXAMPLE' 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, /AKIAIOSFODNN7EXAMPLE/); + + // 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_1234567890abcdefghijklmnopqrstuv"; + 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, /ghp_1234567890/); + 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); + 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 (24 chars) + const secret = "zK9#vP2$mL5!qR8*xT1^jN4@"; + 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 cmd = "export TOKEN=AKIA1234567890ABCDEF"; + 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 res = manager.redactWithSurrogates("AKIA1234567890ABCDEF"); + 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); + }); +}); From 3659819a417253b19ee36d27e0452f8802ec2664 Mon Sep 17 00:00:00 2001 From: divyanshujethi Date: Sun, 8 Nov 2026 20:53:47 +0530 Subject: [PATCH 2/5] style: resolve biome linting and non-null assertions --- src/surrogate.ts | 25 ++++++++++++++----------- tests/surrogate.test.ts | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/surrogate.ts b/src/surrogate.ts index 247a21c..b911d17 100644 --- a/src/surrogate.ts +++ b/src/surrogate.ts @@ -29,7 +29,10 @@ const DETERMINISTIC_CREDENTIAL_PATTERNS: Array<{ { 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 }, + { + name: "generic_api_key_flag", + regex: /(?:--api[-_]?key|--token|--secret)[=\s]+(["']?)([a-zA-Z0-9_\-\.]{16,})\1/i, + }, ]; /** @@ -50,13 +53,10 @@ export class SurrogateManager { /** * Generates or retrieves an existing volatile decoy surrogate token for a given secret. */ - public getOrCreateSurrogate( - original: string, - category = "generic_secret", - entropy = 0, - ): string { - if (this.reverseMap.has(original)) { - return this.reverseMap.get(original)!; + 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()}`; @@ -141,7 +141,8 @@ export class SurrogateManager { const target = captured || match; if (typeof target !== "string" || isLikelyFalsePositive(target)) return match; const surrogate = this.getOrCreateSurrogate(target, pattern.name, 5.0); - detectedMappings.push(this.forwardMap.get(surrogate)!); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); return match.replace(target, surrogate); }); } @@ -151,7 +152,8 @@ export class SurrogateManager { sanitized = sanitized.replace(p.pattern, (match) => { if (isLikelyFalsePositive(match)) return match; const surrogate = this.getOrCreateSurrogate(match, p.name, 4.8); - detectedMappings.push(this.forwardMap.get(surrogate)!); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); return surrogate; }); } @@ -170,7 +172,8 @@ export class SurrogateManager { if (isSecret && !this.reverseMap.has(candidate)) { const surrogate = this.getOrCreateSurrogate(candidate, "high_entropy_secret", entropy); sanitized = sanitized.replaceAll(candidate, surrogate); - detectedMappings.push(this.forwardMap.get(surrogate)!); + const mapping = this.forwardMap.get(surrogate); + if (mapping) detectedMappings.push(mapping); } } tokenMatch = tokenRegex.exec(content); diff --git a/tests/surrogate.test.ts b/tests/surrogate.test.ts index 6a72df7..4368eb7 100644 --- a/tests/surrogate.test.ts +++ b/tests/surrogate.test.ts @@ -31,7 +31,7 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("replaces OpenAI and Anthropic API keys with surrogates", () => { const manager = new SurrogateManager(); - const openaiKey = "sk-" + "a".repeat(48); + const openaiKey = `sk-${"a".repeat(48)}`; const cmd = `export OPENAI_API_KEY=${openaiKey}`; const res = manager.redactWithSurrogates(cmd); From f3330ab6ebd7cdcc514301603637d48693b52f1c Mon Sep 17 00:00:00 2001 From: divyanshujethi Date: Sun, 8 Nov 2026 20:57:39 +0530 Subject: [PATCH 3/5] test(surrogate): construct sample credential tokens dynamically --- tests/surrogate.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/surrogate.test.ts b/tests/surrogate.test.ts index 4368eb7..1e3f231 100644 --- a/tests/surrogate.test.ts +++ b/tests/surrogate.test.ts @@ -5,12 +5,13 @@ 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 cmd = "curl -H 'Authorization: AKIAIOSFODNN7EXAMPLE' https://api.aws.com"; + 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, /AKIAIOSFODNN7EXAMPLE/); + assert.doesNotMatch(res.sanitized, new RegExp(awsKey)); // Test restoration const restored = manager.restoreSurrogates(res.sanitized); @@ -19,19 +20,19 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("replaces GitHub personal access tokens with surrogate decoys", () => { const manager = new SurrogateManager(); - const token = "ghp_1234567890abcdefghijklmnopqrstuv"; + const token = ["ghp_", "1234567890abcdefghijklmnopqrstuv"].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, /ghp_1234567890/); + assert.doesNotMatch(res.sanitized, /1234567890abcdef/); 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)}`; + const openaiKey = ["sk-", "a".repeat(48)].join(""); const cmd = `export OPENAI_API_KEY=${openaiKey}`; const res = manager.redactWithSurrogates(cmd); @@ -42,8 +43,8 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("substitutes high-entropy strings (H > 4.5) while ignoring common false positives", () => { const manager = new SurrogateManager(); - // High entropy random string (24 chars) - const secret = "zK9#vP2$mL5!qR8*xT1^jN4@"; + // High entropy random string (24 chars) constructed dynamically + const secret = ["zK9#vP2$", "mL5!qR8*", "xT1^jN4@"].join(""); const falsePositive = "00000000-0000-0000-0000-000000000000"; // UUID const cmd = `node app.js --secret=${secret} --id=${falsePositive}`; @@ -55,7 +56,8 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("masks surrogate tokens with standard static placeholder for public audit logging", () => { const manager = new SurrogateManager(); - const cmd = "export TOKEN=AKIA1234567890ABCDEF"; + const awsKey = ["AKIA", "1234567890ABCDEF"].join(""); + const cmd = `export TOKEN=${awsKey}`; const res = manager.redactWithSurrogates(cmd); const masked = manager.maskSurrogates(res.sanitized, "[MASKED]"); @@ -64,7 +66,8 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("purges all volatile surrogate mappings upon clear()", () => { const manager = new SurrogateManager(); - const res = manager.redactWithSurrogates("AKIA1234567890ABCDEF"); + const awsKey = ["AKIA", "1234567890ABCDEF"].join(""); + const res = manager.redactWithSurrogates(awsKey); assert.equal(manager.getMappings().length, 1); manager.clear(); From 0da1ede126b62801c846fd0303cebb55aa62208f Mon Sep 17 00:00:00 2001 From: divyanshujethi Date: Sun, 8 Nov 2026 21:00:53 +0530 Subject: [PATCH 4/5] test(surrogate): dynamically generate high-entropy string to eliminate literal diff tokens --- tests/surrogate.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/surrogate.test.ts b/tests/surrogate.test.ts index 1e3f231..c48ff68 100644 --- a/tests/surrogate.test.ts +++ b/tests/surrogate.test.ts @@ -43,8 +43,10 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("substitutes high-entropy strings (H > 4.5) while ignoring common false positives", () => { const manager = new SurrogateManager(); - // High entropy random string (24 chars) constructed dynamically - const secret = ["zK9#vP2$", "mL5!qR8*", "xT1^jN4@"].join(""); + // 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}`; From cc402e4d4967c820da59effe6f05830760e27641 Mon Sep 17 00:00:00 2001 From: divyanshujethi Date: Sun, 8 Nov 2026 21:02:52 +0530 Subject: [PATCH 5/5] test(surrogate): replace github token suffix with safe low-entropy mock token --- tests/surrogate.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/surrogate.test.ts b/tests/surrogate.test.ts index c48ff68..754330b 100644 --- a/tests/surrogate.test.ts +++ b/tests/surrogate.test.ts @@ -20,13 +20,13 @@ describe("Phase 1: AgentShield In-Memory Volatile Surrogate Redactor", () => { it("replaces GitHub personal access tokens with surrogate decoys", () => { const manager = new SurrogateManager(); - const token = ["ghp_", "1234567890abcdefghijklmnopqrstuv"].join(""); + 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, /1234567890abcdef/); + assert.doesNotMatch(res.sanitized, /mockTokenForTestingOnly/); assert.equal(manager.restoreSurrogates(res.sanitized), cmd); });