Skip to content

Commit 0879f09

Browse files
diegoDrp-DevCopilot
andcommitted
Security fix: sanitize access token in logs
- Add sanitization to WorkloadIdentityFederationClient.signJWT() method - Create sanitized copy of headers for logging to prevent token exposure - HTTP client receives real Authorization header (no impact to functionality) - Logger receives sanitized Authorization: '[REDACTED]' header - Add comprehensive regression test that validates token is not leaked in logs Fixes security vulnerability where access token could be exposed in GitHub Actions logs when logger.debug() serialized headers via JSON.stringify(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1206044 commit 0879f09

3 files changed

Lines changed: 139 additions & 2 deletions

File tree

‎dist/main/index.js‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/client/workload_identity_federation.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,14 @@ export class WorkloadIdentityFederationClient extends Client implements AuthClie
239239
payload: claims,
240240
};
241241

242+
// Create sanitized headers for logging to avoid exposing the token
243+
const logHeaders = Object.assign({}, headers);
244+
logHeaders.Authorization = '[REDACTED]';
245+
242246
logger.debug(`Built request`, {
243247
method: `POST`,
244248
path: pth,
245-
headers: headers,
249+
headers: logHeaders,
246250
body: body,
247251
});
248252

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert';
3+
4+
import { Logger } from '../../src/logger';
5+
import { WorkloadIdentityFederationClient } from '../../src/client/workload_identity_federation';
6+
7+
// Extended RecordingLogger that captures the actual objects passed to debug()
8+
// not just string representations
9+
class StructuredRecordingLogger extends Logger {
10+
readonly messages: string[] = [];
11+
readonly debugCalls: any[] = [];
12+
13+
withNamespace(): Logger {
14+
return this;
15+
}
16+
17+
debug(...args: any[]) {
18+
// Capture both the string form (for general debugging) and the structured form
19+
this.messages.push(args.join(' '));
20+
this.debugCalls.push(args);
21+
}
22+
23+
warning(...args: any[]) {
24+
this.messages.push(args.join(' '));
25+
}
26+
}
27+
28+
test('#signJWT does not leak access token in logs', { concurrency: true }, async (suite) => {
29+
await suite.test('sanitizes Authorization header before logging', async () => {
30+
const SUPER_SECRET_ACCESS_TOKEN = 'SUPER_SECRET_ACCESS_TOKEN_123456';
31+
32+
const logger = new StructuredRecordingLogger();
33+
const client = new WorkloadIdentityFederationClient({
34+
logger,
35+
universe: 'googleapis.com',
36+
requestReason: 'test-request-reason',
37+
githubOIDCToken: 'test-oidc-token',
38+
githubOIDCTokenRequestURL: 'https://example.com/',
39+
githubOIDCTokenRequestToken: 'test-authorization-token',
40+
githubOIDCTokenAudience: 'test-audience',
41+
workloadIdentityProviderName:
42+
'projects/123/locations/global/workloadIdentityPools/pool/providers/provider',
43+
serviceAccount: 'test-service@example.com',
44+
});
45+
46+
// Mock getToken to return our secret token
47+
let getTokenCalls = 0;
48+
Object.defineProperty(client, 'getToken', {
49+
value: async () => {
50+
getTokenCalls++;
51+
return SUPER_SECRET_ACCESS_TOKEN;
52+
},
53+
});
54+
55+
// Mock httpClient to capture the request and track what was sent
56+
let httpClientCalls = 0;
57+
let capturedHeaders: any = null;
58+
Object.defineProperty(client, '_httpClient', {
59+
value: {
60+
postJson: async (_path: string, _body: any, headers: any) => {
61+
httpClientCalls++;
62+
capturedHeaders = headers;
63+
return {
64+
statusCode: 200,
65+
result: { signedJwt: 'test-signed-jwt' },
66+
};
67+
},
68+
},
69+
});
70+
71+
// Call signJWT
72+
const result = await client.signJWT({ test: 'claims' });
73+
74+
// Verify getToken was called
75+
assert.strictEqual(getTokenCalls, 1, 'getToken should be called once');
76+
77+
// Verify httpClient was called
78+
assert.strictEqual(httpClientCalls, 1, 'httpClient.postJson should be called once');
79+
80+
// Verify the result
81+
assert.strictEqual(result, 'test-signed-jwt', 'signJWT should return the signed JWT');
82+
83+
// Verify HTTP client received the REAL token in Authorization header
84+
assert.ok(capturedHeaders, 'httpClient should receive headers');
85+
assert.ok(capturedHeaders.Authorization, 'Authorization header should be present');
86+
assert.ok(
87+
capturedHeaders.Authorization.includes(SUPER_SECRET_ACCESS_TOKEN),
88+
`HTTP client should receive real token. Got: ${capturedHeaders.Authorization}`,
89+
);
90+
91+
// Verify logger did NOT receive the real token in plain text
92+
const allLoggerOutput = logger.messages.join('\n');
93+
assert.ok(
94+
!allLoggerOutput.includes(SUPER_SECRET_ACCESS_TOKEN),
95+
`Logger output should NOT contain the secret token`,
96+
);
97+
98+
// Verify logger received sanitized headers
99+
// The logger.debug() should have been called with an object containing sanitized headers
100+
const debugCallWithRequest = logger.debugCalls.find(
101+
(call: any[]) =>
102+
call.length > 0 && typeof call[0] === 'string' && call[0].includes('Built request'),
103+
);
104+
105+
assert.ok(debugCallWithRequest, 'debug() should be called with "Built request" message');
106+
assert.strictEqual(debugCallWithRequest.length, 2, 'debug() should be called with 2 arguments');
107+
108+
const debugObject = debugCallWithRequest[1];
109+
assert.ok(debugObject, 'Second argument to debug() should be the request object');
110+
assert.ok(debugObject.headers, 'Request object should have headers');
111+
112+
// Most importantly: the headers passed to logger should have [REDACTED]
113+
assert.ok(
114+
debugObject.headers.Authorization === '[REDACTED]',
115+
`Logger should receive sanitized Authorization header. Got: ${debugObject.headers.Authorization}`,
116+
);
117+
118+
// The logger should NOT see the real token anywhere in the headers object
119+
assert.ok(
120+
!JSON.stringify(debugObject.headers).includes(SUPER_SECRET_ACCESS_TOKEN),
121+
'Logger headers object should not contain the secret token',
122+
);
123+
124+
// Verify other parts of the request are correct
125+
assert.strictEqual(debugObject.method, 'POST', 'Method should be POST');
126+
assert.ok(debugObject.path.includes('signJwt'), 'Path should include signJwt');
127+
assert.deepStrictEqual(
128+
debugObject.body,
129+
{ payload: { test: 'claims' } },
130+
'Body should contain claims',
131+
);
132+
});
133+
});

0 commit comments

Comments
 (0)