feat(secrets): add secret obfuscation with regex flags support

This commit is contained in:
luk
2026-02-15 16:50:01 +00:00
parent e7d90ff703
commit 2924495bfd
12 changed files with 524 additions and 3 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `transformToolCallArguments` option to `AgentOptions` and `AgentLoopConfig` for transforming tool call arguments before execution (e.g. secret deobfuscation)
## [12.2.0] - 2026-02-13
### Added
+3 -1
View File
@@ -198,6 +198,7 @@ async function runLoop(
config.getSteeringMessages,
config.getToolContext,
config.interruptMode,
config.transformToolCallArguments,
);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
@@ -371,6 +372,7 @@ async function executeToolCalls(
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
getToolContext?: AgentLoopConfig["getToolContext"],
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
@@ -448,7 +450,7 @@ async function executeToolCalls(
: undefined;
result = await tool.execute(
toolCall.id,
validatedArgs,
transformToolCallArguments ? transformToolCallArguments(validatedArgs, toolCall.name) : validatedArgs,
tool.nonAbortable ? undefined : toolSignal,
partialResult => {
if (interruptState.triggered) return;
+9
View File
@@ -123,6 +123,12 @@ export interface AgentOptions {
*/
getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
/**
* Optional transform applied to tool call arguments before execution.
* Use for deobfuscating secrets or rewriting arguments.
*/
transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
/**
* Cursor exec handlers for local tool execution.
*/
@@ -178,6 +184,7 @@ export class Agent {
#resolveRunningPrompt?: () => void;
#kimiApiFormat?: "openai" | "anthropic";
#preferWebsockets?: boolean;
#transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
/** Buffered Cursor tool results with text length at time of call (for correct ordering) */
#cursorToolResultBuffer: CursorToolResultEntry[] = [];
@@ -204,6 +211,7 @@ export class Agent {
this.#cursorOnToolResult = opts.cursorOnToolResult;
this.#kimiApiFormat = opts.kimiApiFormat;
this.#preferWebsockets = opts.preferWebsockets;
this.#transformToolCallArguments = opts.transformToolCallArguments;
}
/**
@@ -626,6 +634,7 @@ export class Agent {
getToolContext: this.#getToolContext,
cursorExecHandlers: this.#cursorExecHandlers,
cursorOnToolResult,
transformToolCallArguments: this.#transformToolCallArguments,
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) {
skipInitialSteeringPoll = false;
+6
View File
@@ -111,6 +111,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
* Use for late-bound UI or session state access.
*/
getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
/**
* Optional transform applied to tool call arguments before execution.
* Use for deobfuscating secrets or rewriting arguments.
*/
transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
}
export interface ToolCallContext {
+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
### Added
- Added secret obfuscation: env vars matching secret patterns and `secrets.json` entries are replaced with placeholders before sending to LLM providers, deobfuscated in tool call arguments
- Added `secrets.enabled` setting to toggle secret obfuscation
- Added full regex literal support for `secrets.json` entries (`"/pattern/flags"` syntax with escaped `/` handling, automatic `g` flag enforcement)
## [12.5.1] - 2026-02-15
### Added
@@ -300,6 +300,15 @@ export const SETTINGS_SCHEMA = {
disabledExtensions: { type: "array", default: [] as string[] },
modelRoles: { type: "record", default: {} as Record<string, string> },
// ─────────────────────────────────────────────────────────────────────────
// Secrets settings
// ─────────────────────────────────────────────────────────────────────────
"secrets.enabled": {
type: "boolean",
default: true,
ui: { tab: "config", label: "Hide secrets", description: "Obfuscate secrets before sending to AI providers" },
},
// ─────────────────────────────────────────────────────────────────────────
// Compaction settings
// ─────────────────────────────────────────────────────────────────────────
+23 -1
View File
@@ -47,6 +47,7 @@ import {
import { disposeAllKernelSessions } from "./ipy/executor";
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
import { buildMemoryToolDeveloperInstructions, startMemoryStartupTask } from "./memories";
import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets";
import { AgentSession } from "./session/agent-session";
import { AuthStorage } from "./session/auth-storage";
import { convertToLlm } from "./session/messages";
@@ -1091,6 +1092,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
});
};
// Load and create secret obfuscator if secrets are enabled
let obfuscator: SecretObfuscator | undefined;
if (settings.get("secrets.enabled")) {
const fileEntries = await loadSecrets(cwd, agentDir);
const envEntries = collectEnvSecrets();
const allEntries = [...envEntries, ...fileEntries];
if (allEntries.length > 0) {
obfuscator = new SecretObfuscator(allEntries);
}
time("loadSecrets");
}
// Final convertToLlm: chain block-images filter with secret obfuscation
const convertToLlmFinal = (messages: AgentMessage[]): Message[] => {
const converted = convertToLlmWithBlockImages(messages);
if (!obfuscator?.hasSecrets()) return converted;
return obfuscateMessages(obfuscator, converted);
};
const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
toolContextStore.setUIContext(uiContext, hasUI);
};
@@ -1110,7 +1130,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel,
tools: initialTools,
},
convertToLlm: convertToLlmWithBlockImages,
convertToLlm: convertToLlmFinal,
sessionId: sessionManager.getSessionId(),
transformContext: extensionRunner
? async messages => {
@@ -1135,6 +1155,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return key;
},
cursorExecHandlers,
transformToolCallArguments: obfuscator?.hasSecrets() ? args => obfuscator!.deobfuscateObject(args) : undefined,
});
cursorEventEmitter = event => agent.emitExternalEvent(event);
debugStartup("sdk:createAgent");
@@ -1171,6 +1192,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
rebuildSystemPrompt,
ttsrManager,
forceCopilotAgentInitiator,
obfuscator,
});
debugStartup("sdk:createAgentSession");
time("createAgentSession");
+114
View File
@@ -0,0 +1,114 @@
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { SecretEntry } from "./obfuscator";
import { compileSecretRegex } from "./regex";
export { obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator";
/**
* Load secrets from project-local and global secrets.json files.
* Project-local entries override global entries with matching content.
*/
export async function loadSecrets(cwd: string, agentDir: string): Promise<SecretEntry[]> {
const projectPath = path.join(cwd, ".omp", "secrets.json");
const globalPath = path.join(agentDir, "secrets.json");
const globalEntries = await loadSecretsFile(globalPath);
const projectEntries = await loadSecretsFile(projectPath);
if (globalEntries.length === 0) return projectEntries;
if (projectEntries.length === 0) return globalEntries;
// Merge: project overrides global by content match
const projectContents = new Set(projectEntries.map(e => e.content));
const merged = [...globalEntries.filter(e => !projectContents.has(e.content)), ...projectEntries];
return merged;
}
/** Minimum env var value length to consider as a secret. */
const MIN_ENV_VALUE_LENGTH = 8;
/** Env var name patterns that indicate secret values. */
const SECRET_ENV_PATTERNS = /(?:KEY|SECRET|TOKEN|PASSWORD|PASS|AUTH|CREDENTIAL|PRIVATE|OAUTH)(?:_|$)/i;
/** Collect environment variable values that look like secrets. */
export function collectEnvSecrets(): SecretEntry[] {
const entries: SecretEntry[] = [];
const seen = new Set<string>();
for (const [name, value] of Object.entries(process.env)) {
if (!value || value.length < MIN_ENV_VALUE_LENGTH) continue;
if (!SECRET_ENV_PATTERNS.test(name)) continue;
if (seen.has(value)) continue;
seen.add(value);
entries.push({ type: "plain", content: value, mode: "obfuscate" });
}
return entries;
}
async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
try {
const raw = await Bun.file(filePath).json();
if (!Array.isArray(raw)) {
logger.warn("secrets.json must be a JSON array", { path: filePath });
return [];
}
const entries: SecretEntry[] = [];
for (let i = 0; i < raw.length; i++) {
const entry = raw[i];
if (!validateEntry(entry, filePath, i)) continue;
entries.push({
type: entry.type,
content: entry.content,
mode: entry.mode ?? "obfuscate",
replacement: entry.replacement,
flags: entry.flags,
});
}
return entries;
} catch (err) {
if (isEnoent(err)) return [];
logger.warn("Failed to load secrets.json", { path: filePath, error: String(err) });
return [];
}
}
function validateEntry(entry: unknown, filePath: string, index: number): entry is SecretEntry {
if (entry === null || typeof entry !== "object") {
logger.warn(`secrets.json[${index}]: entry must be an object`, { path: filePath });
return false;
}
const e = entry as Record<string, unknown>;
if (e.type !== "plain" && e.type !== "regex") {
logger.warn(`secrets.json[${index}]: type must be "plain" or "regex"`, { path: filePath });
return false;
}
if (typeof e.content !== "string" || e.content.length === 0) {
logger.warn(`secrets.json[${index}]: content must be a non-empty string`, { path: filePath });
return false;
}
if (e.mode !== undefined && e.mode !== "obfuscate" && e.mode !== "replace") {
logger.warn(`secrets.json[${index}]: mode must be "obfuscate" or "replace"`, { path: filePath });
return false;
}
if (e.replacement !== undefined && typeof e.replacement !== "string") {
logger.warn(`secrets.json[${index}]: replacement must be a string`, { path: filePath });
return false;
}
if (e.flags !== undefined && typeof e.flags !== "string") {
logger.warn(`secrets.json[${index}]: flags must be a string`, { path: filePath });
return false;
}
if (e.type === "regex") {
try {
compileSecretRegex(e.content as string, e.flags as string | undefined);
} catch (error) {
logger.warn(`secrets.json[${index}]: invalid regex pattern`, {
path: filePath,
pattern: e.content,
error: String(error),
});
return false;
}
}
return true;
}
@@ -0,0 +1,265 @@
import type { Message, TextContent } from "@oh-my-pi/pi-ai";
import { compileSecretRegex } from "./regex";
// ═══════════════════════════════════════════════════════════════════════════
// Types
// ═══════════════════════════════════════════════════════════════════════════
export interface SecretEntry {
type: "plain" | "regex";
content: string;
mode?: "obfuscate" | "replace";
replacement?: string;
flags?: string;
}
// ═══════════════════════════════════════════════════════════════════════════
// Deterministic replacement generation
// ═══════════════════════════════════════════════════════════════════════════
const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
/** Generate a deterministic same-length replacement string from a secret value. */
function generateDeterministicReplacement(secret: string): string {
// Simple hash: use Bun.hash for speed, seed from the secret bytes
const hash = BigInt(Bun.hash(secret));
const chars: string[] = [];
let h = hash;
for (let i = 0; i < secret.length; i++) {
// Mix the hash for each character position
h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
chars.push(REPLACEMENT_CHARS[idx]);
}
return chars.join("");
}
// ═══════════════════════════════════════════════════════════════════════════
// Placeholder format
// ═══════════════════════════════════════════════════════════════════════════
const PLACEHOLDER_PREFIX = "<<$env:S";
const PLACEHOLDER_SUFFIX = ">>";
/** Build an obfuscation placeholder for secret index N, padded to match the secret length. */
function buildPlaceholder(index: number, secretLength: number): string {
// Minimum: <<$env:SN>> = 11 chars for single-digit index
const bare = `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`;
if (secretLength <= bare.length) {
return bare;
}
// Pad with '.' between index and >>
const paddingNeeded = secretLength - bare.length;
const padding = ".".repeat(paddingNeeded);
return `${PLACEHOLDER_PREFIX}${index}=${padding}${PLACEHOLDER_SUFFIX}`;
}
/** Regex to match obfuscation placeholders: <<$env:S<N>=?<padding>?>> */
const PLACEHOLDER_RE = /<<\$env:S(\d+)(?:=[.]*)?>>(?!>)/g;
// ═══════════════════════════════════════════════════════════════════════════
// SecretObfuscator
// ═══════════════════════════════════════════════════════════════════════════
export class SecretObfuscator {
/** Plain secrets: secret → index (known at construction) */
#plainMappings = new Map<string, number>();
/** Regex entries (patterns compiled at construction) */
#regexEntries: Array<{ regex: RegExp; mode: "obfuscate" | "replace"; replacement?: string }> = [];
/** All obfuscate-mode mappings: index → { secret, placeholder } */
#obfuscateMappings = new Map<number, { secret: string; placeholder: string }>();
/** Replace-mode plain mappings: secret → replacement */
#replaceMappings = new Map<string, string>();
/** Reverse lookup for deobfuscation: placeholder → secret */
#deobfuscateMap = new Map<string, string>();
/** Next available index for regex match discoveries */
#nextIndex: number;
/** Whether any secrets were configured */
#hasAny: boolean;
constructor(entries: SecretEntry[]) {
let index = 0;
for (const entry of entries) {
const mode = entry.mode ?? "obfuscate";
if (entry.type === "plain") {
if (mode === "obfuscate") {
const placeholder = buildPlaceholder(index, entry.content.length);
this.#plainMappings.set(entry.content, index);
this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
this.#deobfuscateMap.set(placeholder, entry.content);
index++;
} else {
// replace mode
const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content);
this.#replaceMappings.set(entry.content, replacement);
}
} else {
// regex type — compiled here, matches discovered during obfuscate()
try {
const regex = compileSecretRegex(entry.content, entry.flags);
this.#regexEntries.push({ regex, mode, replacement: entry.replacement });
} catch {
// Invalid regex — skip silently (validation happens at load time)
}
}
}
this.#nextIndex = index;
this.#hasAny = entries.length > 0;
}
hasSecrets(): boolean {
return this.#hasAny;
}
/** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
obfuscate(text: string): string {
if (!this.#hasAny) return text;
let result = text;
// 1. Process replace-mode plain secrets
for (const [secret, replacement] of this.#replaceMappings) {
result = replaceAll(result, secret, replacement);
}
// 2. Process obfuscate-mode plain secrets
for (const [secret, index] of this.#plainMappings) {
const mapping = this.#obfuscateMappings.get(index)!;
result = replaceAll(result, secret, mapping.placeholder);
}
// 3. Process regex entries — discover new matches
for (const entry of this.#regexEntries) {
entry.regex.lastIndex = 0;
const matches = new Set<string>();
for (;;) {
const match = entry.regex.exec(result);
if (match === null) break;
matches.add(match[0]);
}
for (const matchValue of matches) {
if (entry.mode === "replace") {
const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue);
result = replaceAll(result, matchValue, replacement);
} else {
// obfuscate mode — get or create stable index
let index = this.#findObfuscateIndex(matchValue);
if (index === undefined) {
index = this.#nextIndex++;
const placeholder = buildPlaceholder(index, matchValue.length);
this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
this.#deobfuscateMap.set(placeholder, matchValue);
}
const mapping = this.#obfuscateMappings.get(index)!;
result = replaceAll(result, matchValue, mapping.placeholder);
}
}
}
return result;
}
/** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */
deobfuscate(text: string): string {
if (!this.#hasAny) return text;
return text.replace(PLACEHOLDER_RE, match => {
return this.#deobfuscateMap.get(match) ?? match;
});
}
/** Deep-walk an object, deobfuscating all string values. */
deobfuscateObject<T>(obj: T): T {
if (!this.#hasAny) return obj;
return deepWalkStrings(obj, s => this.deobfuscate(s));
}
/** Find the obfuscate index for a known secret value. */
#findObfuscateIndex(secret: string): number | undefined {
// Check plain mappings first
const plainIndex = this.#plainMappings.get(secret);
if (plainIndex !== undefined) return plainIndex;
// Check regex-discovered mappings
for (const [index, mapping] of this.#obfuscateMappings) {
if (mapping.secret === secret) return index;
}
return undefined;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Message obfuscation (outbound to LLM)
// ═══════════════════════════════════════════════════════════════════════════
/** Obfuscate all text content in LLM messages (for outbound interception). */
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
return messages.map(msg => {
if (!Array.isArray(msg.content)) return msg;
let changed = false;
const content = msg.content.map(block => {
if (block.type === "text") {
const obfuscated = obfuscator.obfuscate(block.text);
if (obfuscated !== block.text) {
changed = true;
return { ...block, text: obfuscated } as TextContent;
}
}
return block;
});
return changed ? ({ ...msg, content } as typeof msg) : msg;
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
/** Replace all occurrences of `search` in `text` with `replacement`. */
function replaceAll(text: string, search: string, replacement: string): string {
if (search.length === 0) return text;
let result = text;
let idx = result.indexOf(search);
while (idx !== -1) {
result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
idx = result.indexOf(search, idx + replacement.length);
}
return result;
}
/** Deep-walk an object, transforming all string values. */
function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
if (typeof obj === "string") {
return transform(obj) as unknown as T;
}
if (Array.isArray(obj)) {
let changed = false;
const result = obj.map(item => {
const transformed = deepWalkStrings(item, transform);
if (transformed !== item) changed = true;
return transformed;
});
return (changed ? result : obj) as unknown as T;
}
if (obj !== null && typeof obj === "object") {
let changed = false;
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const value = (obj as Record<string, unknown>)[key];
const transformed = deepWalkStrings(value, transform);
if (transformed !== value) changed = true;
result[key] = transformed;
}
return (changed ? result : obj) as T;
}
return obj;
}
@@ -0,0 +1,9 @@
/** Add global flag while preserving user-provided flags. */
function enforceGlobalFlag(flags: string): string {
return flags.includes("g") ? flags : `${flags}g`;
}
/** Compile a secret regex entry with global scanning enabled by default. */
export function compileSecretRegex(pattern: string, flags?: string): RegExp {
return new RegExp(pattern, enforceGlobalFlag(flags ?? ""));
}
@@ -72,6 +72,7 @@ import type { PlanModeState } from "../plan-mode/state";
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import type { SecretObfuscator } from "../secrets/obfuscator";
import { closeAllConnections } from "../ssh/connection-manager";
import { unmountAll } from "../ssh/sshfs-mount";
import { outputMeta } from "../tools/output-meta";
@@ -155,6 +156,8 @@ export interface AgentSessionConfig {
ttsrManager?: TtsrManager;
/** Force X-Initiator: agent for GitHub Copilot model selections in this session. */
forceCopilotAgentInitiator?: boolean;
/** Secret obfuscator for deobfuscating streaming edit content */
obfuscator?: SecretObfuscator;
}
/** Options for AgentSession.prompt() */
@@ -340,6 +343,7 @@ export class AgentSession {
#streamingEditCheckedLineCounts = new Map<string, number>();
#streamingEditFileCache = new Map<string, string>();
#promptInFlight = false;
#obfuscator: SecretObfuscator | undefined;
#promptGeneration = 0;
#providerSessionState = new Map<string, ProviderSessionState>();
@@ -361,6 +365,7 @@ export class AgentSession {
this.#baseSystemPrompt = this.agent.state.systemPrompt;
this.#ttsrManager = config.ttsrManager;
this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
this.#obfuscator = config.obfuscator;
this.agent.providerSessionState = this.#providerSessionState;
// Always subscribe to agent events for internal handling
@@ -721,7 +726,10 @@ export class AgentSession {
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
if (diffForCheck.trim().length === 0) return;
const normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
if (!normalizedDiff) return;
// Deobfuscate the diff so removed lines match real file content
if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
if (!normalizedDiff) return;
const lines = normalizedDiff.split("\n");
const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
@@ -0,0 +1,67 @@
/**
* Tests for secrets regex parsing, compilation, and obfuscation.
*/
import { describe, expect, it } from "bun:test";
import { SecretObfuscator } from "../src/secrets/obfuscator";
import { compileSecretRegex } from "../src/secrets/regex";
describe("compileSecretRegex", () => {
it("compiles pattern with explicit flags and enforces global scanning", () => {
const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "gi");
expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+");
expect(regex.flags).toBe("gi");
});
it("adds global flag when not provided", () => {
const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "i");
expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+");
expect(regex.flags).toBe("gi");
});
it("defaults to global flag when no flags provided", () => {
const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+");
expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+");
expect(regex.flags).toBe("g");
});
it("rejects invalid regex pattern", () => {
expect(() => compileSecretRegex("(")).toThrow();
});
it("rejects invalid regex flags", () => {
expect(() => compileSecretRegex("x", "zz")).toThrow();
});
});
describe("SecretObfuscator regex behavior", () => {
it("obfuscates and deobfuscates regex matches with flags", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+", flags: "i" }]);
const original = "API_KEY=abc and api-key=def";
const obfuscated = obfuscator.obfuscate(original);
expect(obfuscated).not.toEqual(original);
expect(obfuscator.deobfuscate(obfuscated)).toEqual(original);
});
it("supports bare regex patterns without explicit flags", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+" }]);
const text = "api_key=abc and API_KEY=def";
const obfuscated = obfuscator.obfuscate(text);
expect(obfuscated).not.toEqual(text);
expect(obfuscator.deobfuscate(obfuscated)).toEqual(text);
});
it("deobfuscates placeholders through object payloads", () => {
const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+", flags: "i" }]);
const original = {
cmd: "API_KEY=abc and api-key=def",
status: "ok",
};
const obfuscated = {
cmd: obfuscator.obfuscate(original.cmd),
status: original.status,
};
expect(obfuscator.deobfuscateObject(obfuscated)).toEqual({
cmd: original.cmd,
status: original.status,
});
});
});