Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/experience-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { KnowledgeTracker } from './knowledge-tracker.js';
import type { WebPageState } from './state-manager.js';
import { createDebug, tag } from './utils/logger.js';
import { mdq } from './utils/markdown-query.js';
import { redactSecrets } from './utils/secrets.js';
import { isNonReusableCode } from './utils/step-analyzer.ts';
import { extractStatePath } from './utils/url-matcher.js';

Expand Down Expand Up @@ -109,7 +110,7 @@ export class ExperienceTracker {
return;
}
const filePath = this.getExperienceFilePath(stateHash);
const fileContent = matter.stringify(content, frontmatter || {});
const fileContent = matter.stringify(redactSecrets(content), frontmatter || {});
writeFileSync(filePath, fileContent, 'utf8');
}

Expand Down
8 changes: 7 additions & 1 deletion src/knowledge-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ActionResult } from './action-result.js';
import { ConfigParser } from './config.js';
import { getCliName } from './utils/cli-name.ts';
import { createDebug } from './utils/logger.js';
import { isSecretName, registerSecret } from './utils/secrets.js';

const debugLog = createDebug('explorbot:knowledge-tracker');

Expand Down Expand Up @@ -138,9 +139,14 @@ export class KnowledgeTracker {
const namespace = expr.slice(0, dotIndex);
const key = expr.slice(dotIndex + 1);

if (namespace === 'env') return process.env[key] ?? '';
if (namespace === 'env') {
const value = process.env[key] ?? '';
if (isSecretName(key)) registerSecret(value);
return value;
}

if (namespace === 'config') {
if (isSecretName(key)) return '';
const config = ConfigParser.getInstance().getConfig();
const value = key.split('.').reduce((obj: any, k) => obj?.[k], config);
if (value !== undefined && typeof value !== 'object') return String(value);
Expand Down
3 changes: 2 additions & 1 deletion src/observability.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Span, trace } from '@opentelemetry/api';
import { redactSecrets } from './utils/secrets.js';

type TelemetryMetadata = Record<string, unknown>;

Expand Down Expand Up @@ -84,7 +85,7 @@ function buildRootSpanAttributes(name: string, metadata: TelemetryMetadata): Rec
attributes['langfuse.trace.tags'] = metadata.tags as string[];
}
if (metadata.input !== undefined) {
attributes['langfuse.trace.input'] = JSON.stringify(metadata.input);
attributes['langfuse.trace.input'] = redactSecrets(JSON.stringify(metadata.input));
}

return attributes;
Expand Down
28 changes: 28 additions & 0 deletions src/utils/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const MIN_SECRET_LENGTH = 4;
const SECRET_NAME_TOKENS = ['password', 'passwd', 'secret', 'token', 'apikey', 'api_key', 'credential', 'private_key', 'privatekey', 'access_key'];
const secretValues = new Set<string>();

export function registerSecret(value: string): void {
if (!value) return;
if (value.length < MIN_SECRET_LENGTH) return;
secretValues.add(value);
}

export function redactSecrets(text: string): string {
if (!text) return text;
let result = text;
for (const value of [...secretValues].sort((a, b) => b.length - a.length)) {
if (!result.includes(value)) continue;
result = result.split(value).join('***REDACTED***');
}
return result;
}

export function clearRegisteredSecrets(): void {
secretValues.clear();
}

export function isSecretName(name: string): boolean {
const lower = name.toLowerCase();
return SECRET_NAME_TOKENS.some((token) => lower.includes(token));
}
28 changes: 28 additions & 0 deletions tests/unit/knowledge-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
import matter from 'gray-matter';
import { ConfigParser } from '../../src/config';
import { KnowledgeTracker } from '../../src/knowledge-tracker';
import { clearRegisteredSecrets, redactSecrets } from '../../src/utils/secrets';

const knowledgeDir = '/tmp/explorbot-test-knowledge';

Expand Down Expand Up @@ -124,5 +125,32 @@ describe('KnowledgeTracker', () => {
expect(content[0]).toContain('value:');
expect(content[0]).not.toContain('${config.');
});

it('should block credential-named config keys from interpolating', () => {
(ConfigParser.getInstance() as any).config.ai.apiKey = 'sk-should-not-leak';
writeKnowledgeFile('page.md', '/page', 'key: ${config.ai.apiKey}');

const tracker = new KnowledgeTracker();
const content = tracker.getKnowledgeForUrl('/page');

expect(content[0]).not.toContain('sk-should-not-leak');
expect(content[0]).not.toContain('${config.');
});

it('should register env secrets so they are redacted at sinks', () => {
clearRegisteredSecrets();
process.env.APP_PASSWORD = 'hunter2secret';

writeKnowledgeFile('login.md', '/login', 'password: ${env.APP_PASSWORD}');

const tracker = new KnowledgeTracker();
const content = tracker.getKnowledgeForUrl('/login');

expect(content[0]).toContain('password: hunter2secret');
expect(redactSecrets('typed hunter2secret into the field')).toBe('typed ***REDACTED*** into the field');

process.env.APP_PASSWORD = undefined;
clearRegisteredSecrets();
});
});
});
42 changes: 42 additions & 0 deletions tests/unit/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from 'bun:test';
import { clearRegisteredSecrets, isSecretName, redactSecrets, registerSecret } from '../../src/utils/secrets';

describe('secrets', () => {
beforeEach(() => {
clearRegisteredSecrets();
});

it('redacts a registered value', () => {
registerSecret('hunter2secret');
expect(redactSecrets('login with hunter2secret now')).toBe('login with ***REDACTED*** now');
});

it('passes unregistered values through unchanged', () => {
registerSecret('hunter2secret');
expect(redactSecrets('nothing to hide here')).toBe('nothing to hide here');
});

it('does not redact short values', () => {
registerSecret('12');
expect(redactSecrets('code 12 is fine')).toBe('code 12 is fine');
});

it('replaces every occurrence', () => {
registerSecret('topsecret');
expect(redactSecrets('topsecret and topsecret again')).toBe('***REDACTED*** and ***REDACTED*** again');
});

it('redacts a longer secret fully even when a registered secret is its substring', () => {
registerSecret('hunter2');
registerSecret('hunter2extended');
expect(redactSecrets('login with hunter2extended now')).toBe('login with ***REDACTED*** now');
});

it('detects credential-named keys', () => {
expect(isSecretName('APP_PASSWORD')).toBe(true);
expect(isSecretName('ai.apiKey')).toBe(true);
expect(isSecretName('SESSION_TOKEN')).toBe(true);
expect(isSecretName('BASE_URL')).toBe(false);
expect(isSecretName('playwright.browser')).toBe(false);
});
});
Loading