Skip to content

Commit db90638

Browse files
authored
Merge pull request #310 from ShipSecAI/codex/eng-179-redact-sensitive-logs
fix(logging): redact sensitive tokens in workflow logs
2 parents 557918a + c8b5412 commit db90638

7 files changed

Lines changed: 183 additions & 8 deletions

File tree

backend/src/auth/providers/clerk-auth.provider.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,7 @@ export class ClerkAuthProvider implements AuthProviderStrategy {
6969

7070
private async verifyClerkToken(token: string): Promise<ClerkJwt> {
7171
try {
72-
// Log token preview for debugging (first 20 chars)
73-
this.logger.log(`[AUTH] Verifying token (preview: ${token.substring(0, 20)}...)`);
72+
this.logger.log('[AUTH] Verifying token');
7473

7574
// Add clock skew tolerance to handle server clock differences
7675
// Clerk tokens can have iat in the future due to clock skew between servers
@@ -88,7 +87,6 @@ export class ClerkAuthProvider implements AuthProviderStrategy {
8887
} catch (error) {
8988
const message = error instanceof Error ? error.message : String(error);
9089
this.logger.error(`[AUTH] Clerk token verification failed: ${message}`);
91-
this.logger.error(`[AUTH] Token preview: ${token.substring(0, 50)}...`);
9290
this.logger.error(`[AUTH] Secret key configured: ${this.config.secretKey ? 'yes' : 'no'}`);
9391
throw new UnauthorizedException('Invalid Clerk token');
9492
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
2+
3+
import { LogIngestService } from '../log-ingest.service';
4+
import type { LogStreamRepository } from '../../trace/log-stream.repository';
5+
6+
describe('LogIngestService', () => {
7+
const originalEnv = { ...process.env };
8+
9+
beforeEach(() => {
10+
process.env.LOG_KAFKA_BROKERS = 'localhost:9092';
11+
process.env.LOKI_URL = 'http://localhost:3100';
12+
});
13+
14+
afterEach(() => {
15+
process.env = { ...originalEnv };
16+
});
17+
18+
it('redacts sensitive data before pushing to Loki', async () => {
19+
const repository = {
20+
upsertMetadata: mock(async () => undefined),
21+
} as unknown as LogStreamRepository;
22+
23+
const service = new LogIngestService(repository);
24+
const push = mock(async () => undefined);
25+
(service as any).lokiClient = { push };
26+
27+
await (service as any).processEntry({
28+
runId: 'run-1',
29+
nodeRef: 'node-1',
30+
stream: 'stdout',
31+
message: 'token=abc123 authorization=Bearer super-secret',
32+
timestamp: '2026-02-21T00:00:00.000Z',
33+
organizationId: 'org-1',
34+
});
35+
36+
expect(push).toHaveBeenCalledTimes(1);
37+
const call = push.mock.calls[0] as unknown[] | undefined;
38+
expect(call).toBeTruthy();
39+
const lines = (call?.[1] ?? []) as { message: string }[];
40+
expect(lines).toHaveLength(1);
41+
expect(lines[0]?.message).toContain('token=[REDACTED]');
42+
expect(lines[0]?.message).toContain('authorization=[REDACTED]');
43+
expect(lines[0]?.message).not.toContain('abc123');
44+
expect(lines[0]?.message).not.toContain('super-secret');
45+
});
46+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { redactSensitiveData } from '../redact-sensitive';
4+
5+
describe('redactSensitiveData', () => {
6+
it('redacts common secret key-value pairs', () => {
7+
const input =
8+
'authorization=Bearer abcdefghijklmnop token=123456 password=hunter2 api_key=xyz987';
9+
const redacted = redactSensitiveData(input);
10+
11+
expect(redacted).toContain('authorization=[REDACTED]');
12+
expect(redacted).toContain('token=[REDACTED]');
13+
expect(redacted).toContain('password=[REDACTED]');
14+
expect(redacted).toContain('api_key=[REDACTED]');
15+
});
16+
17+
it('redacts JSON-style secret fields', () => {
18+
const input = '{"access_token":"abc123","client_secret":"super-secret"}';
19+
const redacted = redactSensitiveData(input);
20+
21+
expect(redacted).toBe('{"access_token":"[REDACTED]","client_secret":"[REDACTED]"}');
22+
});
23+
24+
it('redacts token-like standalone values and URL params', () => {
25+
const input =
26+
'https://example.com?token=abc123&foo=1 Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aGVsbG8td29ybGQ.signature ghp_abcdefghijklmnopqrstuvwxyz1234 sk-abcdefghijklmnopqrstuvwxyz123456';
27+
const redacted = redactSensitiveData(input);
28+
29+
expect(redacted).toContain('?token=[REDACTED]&foo=1');
30+
expect(redacted).not.toContain('eyJhbGciOiJIUzI1Ni');
31+
expect(redacted).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz1234');
32+
expect(redacted).not.toContain('sk-abcdefghijklmnopqrstuvwxyz123456');
33+
});
34+
35+
it('redacts github clone URLs with embedded x-access-token credentials', () => {
36+
const input =
37+
'CLONE_URL=https://x-access-token:ghs_abcdefghijklmnopqrstuvwxyz1234567890@github.com/LuD1161/git-test-repo.git';
38+
const redacted = redactSensitiveData(input);
39+
40+
expect(redacted).toContain('CLONE_URL=https://x-access-token:[REDACTED]@github.com/');
41+
expect(redacted).not.toContain('ghs_abcdefghijklmnopqrstuvwxyz1234567890');
42+
});
43+
44+
it('preserves non-sensitive text', () => {
45+
const input = 'workflow finished successfully in 245ms';
46+
expect(redactSensitiveData(input)).toBe(input);
47+
});
48+
});

backend/src/logging/log-ingest.service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getTopicResolver } from '../common/kafka-topic-resolver';
55
import { LogStreamRepository } from '../trace/log-stream.repository';
66
import type { KafkaLogEntry } from './log-entry.types';
77
import { LokiLogClient } from './loki.client';
8+
import { redactSensitiveData } from './redact-sensitive';
89

910
@Injectable()
1011
export class LogIngestService implements OnModuleInit, OnModuleDestroy {
@@ -108,13 +109,14 @@ export class LogIngestService implements OnModuleInit, OnModuleDestroy {
108109
}
109110

110111
private async processEntry(entry: KafkaLogEntry): Promise<void> {
111-
if (!entry.message || entry.message.trim().length === 0) {
112+
const sanitizedMessage = redactSensitiveData(entry.message ?? '');
113+
if (!sanitizedMessage || sanitizedMessage.trim().length === 0) {
112114
return;
113115
}
114116

115117
const timestamp = entry.timestamp ? new Date(entry.timestamp) : new Date();
116118
const labels = this.buildLabels(entry);
117-
const lines = this.buildLines(entry.message, timestamp);
119+
const lines = this.buildLines(sanitizedMessage, timestamp);
118120
if (!lines.length) {
119121
return;
120122
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const REDACTED = '[REDACTED]';
2+
3+
// Patterns for high-signal secret forms commonly seen in logs.
4+
const SECRET_KEY_PATTERN =
5+
'(?:access_token|refresh_token|id_token|token|api[_-]?key|apikey|client_secret|secret|password|authorization|x-api-key|private_key|session_token)';
6+
7+
const JSON_SECRET_PAIR_REGEX = new RegExp(
8+
`("(${SECRET_KEY_PATTERN})"\\s*:\\s*")([^"\\r\\n]{3,})(")`,
9+
'gi',
10+
);
11+
const AUTH_SCHEME_ASSIGNMENT_REGEX = /\bauthorization\b\s*([=:])\s*(?:Bearer|Basic)\s+[^\s,;&]+/gi;
12+
const ASSIGNMENT_SECRET_PAIR_REGEX = new RegExp(
13+
`(\\b${SECRET_KEY_PATTERN}\\b\\s*[=:]\\s*)([^\\s,;&@]+)`,
14+
'gi',
15+
);
16+
const URL_SECRET_PARAM_REGEX = new RegExp(`([?&](?:${SECRET_KEY_PATTERN})=)([^&#\\s]+)`, 'gi');
17+
const BEARER_REGEX = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi;
18+
const BASIC_REGEX = /\bBasic\s+[A-Za-z0-9+/=]{8,}\b/gi;
19+
const JWT_REGEX = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
20+
const GITHUB_TOKEN_REGEX = /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g;
21+
const GENERIC_SK_TOKEN_REGEX = /\bsk-[A-Za-z0-9]{20,}\b/g;
22+
23+
export function redactSensitiveData(input: string): string {
24+
if (!input) {
25+
return input;
26+
}
27+
28+
let output = input;
29+
30+
output = output.replace(JSON_SECRET_PAIR_REGEX, `$1${REDACTED}$4`);
31+
output = output.replace(AUTH_SCHEME_ASSIGNMENT_REGEX, `authorization$1${REDACTED}`);
32+
output = output.replace(ASSIGNMENT_SECRET_PAIR_REGEX, `$1${REDACTED}`);
33+
output = output.replace(URL_SECRET_PARAM_REGEX, `$1${REDACTED}`);
34+
35+
output = output.replace(BEARER_REGEX, `Bearer ${REDACTED}`);
36+
output = output.replace(BASIC_REGEX, `Basic ${REDACTED}`);
37+
output = output.replace(JWT_REGEX, REDACTED);
38+
output = output.replace(GITHUB_TOKEN_REGEX, REDACTED);
39+
output = output.replace(GENERIC_SK_TOKEN_REGEX, REDACTED);
40+
41+
return output;
42+
}

backend/src/trace/__tests__/log-stream.service.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,38 @@ describe('LogStreamService', () => {
147147
expect(calledUrl).toContain(`start=${firstNs}`);
148148
expect(calledUrl).toContain(`end=${lastNs}`);
149149
});
150+
151+
it('redacts sensitive values returned from Loki', async () => {
152+
const nanoTs = (BigInt(record.firstTimestamp.getTime()) * 1000000n).toString();
153+
154+
// @ts-expect-error override global fetch for test
155+
global.fetch = async () =>
156+
({
157+
ok: true,
158+
json: async () => ({
159+
data: {
160+
result: [
161+
{
162+
values: [[nanoTs, 'token=abc123 authorization=Bearer super-secret-value']],
163+
},
164+
],
165+
},
166+
}),
167+
}) as Response;
168+
169+
const repository = {
170+
listByRunId: async () => [record],
171+
} as unknown as LogStreamRepository;
172+
const service = new LogStreamService(repository);
173+
const result = await service.fetch('run-123', authContext, {
174+
nodeRef: 'node-1',
175+
stream: 'stdout',
176+
});
177+
178+
expect(result.logs).toHaveLength(1);
179+
expect(result.logs[0]?.message).toContain('token=[REDACTED]');
180+
expect(result.logs[0]?.message).toContain('authorization=[REDACTED]');
181+
expect(result.logs[0]?.message).not.toContain('abc123');
182+
expect(result.logs[0]?.message).not.toContain('super-secret-value');
183+
});
150184
});

backend/src/trace/log-stream.service.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ForbiddenException, Injectable, ServiceUnavailableException } from '@ne
33
import { LogStreamRepository } from './log-stream.repository';
44
import type { WorkflowLogStreamRecord } from '../database/schema';
55
import type { AuthContext } from '../auth/types';
6+
import { redactSensitiveData } from '../logging/redact-sensitive';
67

78
interface FetchLogsOptions {
89
nodeRef?: string;
@@ -215,7 +216,7 @@ export class LogStreamService {
215216
for (const [timestamp, message] of result.values ?? []) {
216217
entries.push({
217218
timestamp: this.fromNanoseconds(timestamp),
218-
message,
219+
message: this.sanitizeMessage(message),
219220
});
220221
}
221222
}
@@ -265,7 +266,7 @@ export class LogStreamService {
265266
for (const [timestamp, message] of result.values ?? []) {
266267
entries.push({
267268
timestamp: this.fromNanoseconds(timestamp),
268-
message,
269+
message: this.sanitizeMessage(message),
269270
level: streamLabels.level,
270271
nodeId: streamLabels.node,
271272
});
@@ -336,7 +337,7 @@ export class LogStreamService {
336337
for (const [timestamp, message] of result.values ?? []) {
337338
entries.push({
338339
timestamp: this.fromNanoseconds(timestamp),
339-
message,
340+
message: this.sanitizeMessage(message),
340341
level: streamLabels.level,
341342
nodeId: streamLabels.node,
342343
});
@@ -421,6 +422,10 @@ export class LogStreamService {
421422
return new Date(millis).toISOString();
422423
}
423424

425+
private sanitizeMessage(message: string): string {
426+
return redactSensitiveData(message);
427+
}
428+
424429
private requireOrganizationId(auth: AuthContext | null): string {
425430
const organizationId = auth?.organizationId;
426431
if (!organizationId) {

0 commit comments

Comments
 (0)