Skip to content

Commit cd04346

Browse files
authored
Merge pull request #6 from fpr1m3/sentinel/fix-secret-leakage-17186065940338473560
🛡️ Sentinel: [HIGH] Redact secrets from logs
2 parents ca79f1c + 9d01f4b commit cd04346

File tree

7 files changed

+237
-7
lines changed

7 files changed

+237
-7
lines changed

dist/lib/logger.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } fr
22
import { dirname, join } from 'path';
33
import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
44
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
5+
import { redactString, redactObject } from './redaction';
56
export class Logger {
67
sessionId;
78
toolsUsed = new Set();
@@ -89,7 +90,7 @@ export class Logger {
8990
if (tool === 'Bash' || tool === 'bash') {
9091
const command = props?.input?.command || props?.tool_input?.command;
9192
if (command)
92-
this.commandsExecuted.push(command);
93+
this.commandsExecuted.push(redactString(command));
9394
}
9495
if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
9596
const path = props?.input?.file_path || props?.input?.path ||
@@ -99,7 +100,7 @@ export class Logger {
99100
}
100101
}
101102
}
102-
this.writeEvent(anyEvent.type, payload);
103+
this.writeEvent(anyEvent.type, redactObject(payload));
103104
}
104105
/**
105106
* Log tool execution from tool.execute.after hook
@@ -127,7 +128,7 @@ export class Logger {
127128
tool_metadata: metadata,
128129
call_id: input.callID,
129130
};
130-
this.writeEvent('ToolUse', payload, toolName, metadata);
131+
this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
131132
}
132133
async generateSessionSummary() {
133134
try {

dist/lib/redaction.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Redaction utility to scrub sensitive data from logs
3+
*/
4+
export declare function redactString(str: string): string;
5+
export declare function redactObject(obj: any): any;

dist/lib/redaction.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Redaction utility to scrub sensitive data from logs
3+
*/
4+
const SENSITIVE_KEYS = [
5+
'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
6+
'auth', 'credential', 'private_key', 'client_secret', 'access_key'
7+
];
8+
const SECRET_PATTERNS = [
9+
// AWS Access Key ID
10+
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
11+
// GitHub Personal Access Token (classic)
12+
/\bghp_[a-zA-Z0-9]{36}\b/g,
13+
// Generic Private Key
14+
/-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
15+
// Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
16+
/\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
17+
];
18+
// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
19+
// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
20+
// We construct this dynamically from SENSITIVE_KEYS
21+
const SENSITIVE_KEY_PATTERN = new RegExp(`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, 'gi');
22+
export function redactString(str) {
23+
if (!str)
24+
return str;
25+
let redacted = str;
26+
// 1. Redact specific patterns (like AWS keys)
27+
for (const pattern of SECRET_PATTERNS) {
28+
redacted = redacted.replace(pattern, '[REDACTED]');
29+
}
30+
// 2. Redact key-value pairs where key suggests sensitivity
31+
// We use a callback to preserve the key and redact the value
32+
redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
33+
// If value is already redacted, skip
34+
if (value === '[REDACTED]')
35+
return match;
36+
// Replace the value part with [REDACTED]
37+
return match.replace(value, '[REDACTED]');
38+
});
39+
return redacted;
40+
}
41+
export function redactObject(obj) {
42+
if (obj === null || obj === undefined)
43+
return obj;
44+
if (typeof obj === 'string') {
45+
return redactString(obj);
46+
}
47+
if (Array.isArray(obj)) {
48+
return obj.map(item => redactObject(item));
49+
}
50+
if (typeof obj === 'object') {
51+
const newObj = {};
52+
for (const [key, value] of Object.entries(obj)) {
53+
// If the key itself is sensitive, redact the value blindly if it's a string/number
54+
const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
55+
if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
56+
newObj[key] = '[REDACTED]';
57+
}
58+
else {
59+
newObj[key] = redactObject(value);
60+
}
61+
}
62+
return newObj;
63+
}
64+
return obj;
65+
}

src/lib/logger.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname, join } from 'path';
33
import type { Event } from '@opencode-ai/sdk';
44
import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
55
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
6+
import { redactString, redactObject } from './redaction';
67

78
interface HookEvent {
89
source_app: string;
@@ -115,7 +116,7 @@ export class Logger {
115116

116117
if (tool === 'Bash' || tool === 'bash') {
117118
const command = props?.input?.command || props?.tool_input?.command;
118-
if (command) this.commandsExecuted.push(command);
119+
if (command) this.commandsExecuted.push(redactString(command));
119120
}
120121

121122
if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
@@ -126,7 +127,7 @@ export class Logger {
126127
}
127128
}
128129

129-
this.writeEvent(anyEvent.type, payload);
130+
this.writeEvent(anyEvent.type, redactObject(payload));
130131
}
131132

132133
/**
@@ -162,7 +163,7 @@ export class Logger {
162163
call_id: input.callID,
163164
};
164165

165-
this.writeEvent('ToolUse', payload, toolName, metadata);
166+
this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
166167
}
167168

168169
public async generateSessionSummary(): Promise<string | null> {

src/lib/redaction.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Redaction utility to scrub sensitive data from logs
3+
*/
4+
5+
const SENSITIVE_KEYS = [
6+
'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
7+
'auth', 'credential', 'private_key', 'client_secret', 'access_key'
8+
];
9+
10+
const SECRET_PATTERNS = [
11+
// AWS Access Key ID
12+
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
13+
// GitHub Personal Access Token (classic)
14+
/\bghp_[a-zA-Z0-9]{36}\b/g,
15+
// Generic Private Key
16+
/-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
17+
// Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
18+
/\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
19+
];
20+
21+
// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
22+
// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
23+
// We construct this dynamically from SENSITIVE_KEYS
24+
const SENSITIVE_KEY_PATTERN = new RegExp(
25+
`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`,
26+
'gi'
27+
);
28+
29+
export function redactString(str: string): string {
30+
if (!str) return str;
31+
32+
let redacted = str;
33+
34+
// 1. Redact specific patterns (like AWS keys)
35+
for (const pattern of SECRET_PATTERNS) {
36+
redacted = redacted.replace(pattern, '[REDACTED]');
37+
}
38+
39+
// 2. Redact key-value pairs where key suggests sensitivity
40+
// We use a callback to preserve the key and redact the value
41+
redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
42+
// If value is already redacted, skip
43+
if (value === '[REDACTED]') return match;
44+
// Replace the value part with [REDACTED]
45+
return match.replace(value, '[REDACTED]');
46+
});
47+
48+
return redacted;
49+
}
50+
51+
export function redactObject(obj: any, visited = new WeakSet<any>()): any {
52+
if (obj === null || obj === undefined) return obj;
53+
54+
if (typeof obj === 'string') {
55+
return redactString(obj);
56+
}
57+
58+
if (typeof obj !== 'object') {
59+
return obj;
60+
}
61+
62+
if (obj instanceof Date) {
63+
return obj;
64+
}
65+
66+
if (visited.has(obj)) {
67+
return '[CIRCULAR]';
68+
}
69+
visited.add(obj);
70+
71+
if (Array.isArray(obj)) {
72+
return obj.map(item => redactObject(item, visited));
73+
}
74+
75+
if (typeof obj === 'object') {
76+
const newObj: any = {};
77+
for (const [key, value] of Object.entries(obj)) {
78+
// If the key itself is sensitive, redact the value blindly if it's a string/number
79+
const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
80+
if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
81+
newObj[key] = '[REDACTED]';
82+
} else {
83+
newObj[key] = redactObject(value, visited);
84+
}
85+
}
86+
return newObj;
87+
}
88+
89+
return obj;
90+
}

src/lib/security.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { redactString } from './redaction';
2+
13
/**
24
* Security Library for PAI Plugin
35
* Ported from legacy security-validator.ts
@@ -54,7 +56,7 @@ export function validateCommand(command: string): SecurityResult {
5456
return {
5557
status: 'deny',
5658
category,
57-
feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${command.slice(0, 50)}...`,
59+
feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${redactString(command).slice(0, 50)}...`,
5860
};
5961
}
6062
}

tests/redaction.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import { redactString, redactObject } from '../src/lib/redaction';
3+
4+
describe('Redaction Utility', () => {
5+
it('should redact AWS keys', () => {
6+
const input = 'export AWS_SECRET_KEY=AKIAIOSFODNN7EXAMPLE';
7+
const output = redactString(input);
8+
expect(output).toContain('AWS_SECRET_KEY=[REDACTED]');
9+
expect(output).not.toContain('AKIAIOSFODNN7EXAMPLE');
10+
});
11+
12+
it('should redact sensitive key-value pairs', () => {
13+
const input = 'password="superSecretPassword"';
14+
const output = redactString(input);
15+
expect(output).toContain('password="[REDACTED]"');
16+
expect(output).not.toContain('superSecretPassword');
17+
});
18+
19+
it('should redact generic secrets', () => {
20+
const input = 'my_secret_token = abcdef1234567890';
21+
const output = redactString(input);
22+
expect(output).toContain('my_secret_token = [REDACTED]');
23+
expect(output).not.toContain('abcdef1234567890');
24+
});
25+
26+
it('should redact inside objects', () => {
27+
const input = {
28+
user: 'alice',
29+
credentials: {
30+
password: 'password123',
31+
apiKey: 'AKIAIOSFODNN7EXAMPLE'
32+
}
33+
};
34+
const output = redactObject(input);
35+
expect(output.user).toBe('alice');
36+
expect(output.credentials.password).toBe('[REDACTED]');
37+
expect(output.credentials.apiKey).toBe('[REDACTED]');
38+
});
39+
40+
it('should not redact harmless values', () => {
41+
const input = 'user_id=123';
42+
const output = redactString(input);
43+
expect(output).toBe(input);
44+
});
45+
46+
it('should redact short values in objects if key is sensitive', () => {
47+
const obj = { password: '123' };
48+
const output = redactObject(obj);
49+
expect(output.password).toBe('[REDACTED]');
50+
});
51+
52+
it('should handle circular references', () => {
53+
const obj: any = { name: 'circular' };
54+
obj.self = obj;
55+
const output = redactObject(obj);
56+
expect(output.name).toBe('circular');
57+
expect(output.self).toBe('[CIRCULAR]');
58+
});
59+
60+
it('should preserve Date objects', () => {
61+
const date = new Date('2023-01-01');
62+
const obj = { created_at: date };
63+
const output = redactObject(obj);
64+
expect(output.created_at).toEqual(date);
65+
});
66+
});

0 commit comments

Comments
 (0)