From e8a239373fcff279439998aae67f10d80f314f63 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Thu, 4 Jun 2026 08:39:56 +0530 Subject: [PATCH 1/5] Add GitHub token rotation validation (Issue #3575) Validates tokens before use to prevent silent failures. Checks token format and length before rotation. Fixes #3575 --- lib/token-rotation-validator.ts | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 lib/token-rotation-validator.ts diff --git a/lib/token-rotation-validator.ts b/lib/token-rotation-validator.ts new file mode 100644 index 000000000..3daf420be --- /dev/null +++ b/lib/token-rotation-validator.ts @@ -0,0 +1,46 @@ +/** + * lib/token-rotation-validator.ts + * + * GitHub token rotation validation. + * Validates tokens before use to prevent silent failures. + */ + +export function isValidGitHubToken(token: string): boolean { + if (!token || typeof token !== 'string') { + return false; + } + return token.startsWith('ghp_') || token.startsWith('ghu_') || token.startsWith('ghs_') || token.startsWith('ghr_'); +} + +export function validateTokenBeforeUse(token: string): { valid: boolean; reason?: string } { + if (!token) { + return { valid: false, reason: 'Token is empty or undefined' }; + } + + if (!isValidGitHubToken(token)) { + return { valid: false, reason: 'Invalid GitHub token format' }; + } + + if (token.length < 36) { + return { valid: false, reason: 'Token is too short' }; + } + + return { valid: true }; +} + +export function getNextToken(tokens: string[]): { token: string; index: number } | null { + if (!Array.isArray(tokens) || tokens.length === 0) { + return null; + } + + const validTokens = tokens.filter((t) => validateTokenBeforeUse(t).valid); + if (validTokens.length === 0) { + throw new Error('No valid GitHub tokens available'); + } + + const currentIndex = Math.floor(Math.random() * validTokens.length); + return { + token: validTokens[currentIndex], + index: tokens.indexOf(validTokens[currentIndex]), + }; +} From 503d57512753ca5a3e4a75c1c2a1e2380dfc018d Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Sat, 6 Jun 2026 13:44:09 +0530 Subject: [PATCH 2/5] Integrate token format validation into getGitHubTokens Add isValidGitHubTokenFormat() inline in lib/github.ts and apply it as a filter inside getGitHubTokens(). Tokens that do not match a known GitHub PAT prefix (ghp_, ghu_, ghs_, ghr_, github_pat_) or are shorter than 36 characters are silently rejected before any API call is attempted, preventing cryptic downstream failures when malformed or placeholder strings end up in the environment variable. Remove the standalone lib/token-rotation-validator.ts as the logic now lives directly in the file that uses it. Fixes #3575 --- lib/github.ts | 14 +++++++--- lib/token-rotation-validator.ts | 46 --------------------------------- 2 files changed, 11 insertions(+), 49 deletions(-) delete mode 100644 lib/token-rotation-validator.ts diff --git a/lib/github.ts b/lib/github.ts index 859435746..c04df312a 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -45,7 +45,6 @@ export function getGlobalCircuitBreakerOpenUntilForTests() { return globalCircuitBreakerOpenUntil; } -//Explicit, strongly-typed Error subclass export class RateLimitError extends Error { constructor( message: string, @@ -56,15 +55,24 @@ export class RateLimitError extends Error { } } -// Global circuit state tracking let globalCircuitBreakerOpenUntil = 0; +function isValidGitHubTokenFormat(token: string): boolean { + return ( + token.length >= 36 && + (token.startsWith('ghp_') || + token.startsWith('ghu_') || + token.startsWith('ghs_') || + token.startsWith('ghr_') || + token.startsWith('github_pat_')) + ); +} export function getGitHubTokens(): string[] { const envToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || ''; return envToken .split(',') .map((t) => t.trim()) - .filter((t) => t !== ''); + .filter((t) => t !== '' && isValidGitHubTokenFormat(t)); } function isAbortError(error: unknown): boolean { diff --git a/lib/token-rotation-validator.ts b/lib/token-rotation-validator.ts deleted file mode 100644 index 3daf420be..000000000 --- a/lib/token-rotation-validator.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * lib/token-rotation-validator.ts - * - * GitHub token rotation validation. - * Validates tokens before use to prevent silent failures. - */ - -export function isValidGitHubToken(token: string): boolean { - if (!token || typeof token !== 'string') { - return false; - } - return token.startsWith('ghp_') || token.startsWith('ghu_') || token.startsWith('ghs_') || token.startsWith('ghr_'); -} - -export function validateTokenBeforeUse(token: string): { valid: boolean; reason?: string } { - if (!token) { - return { valid: false, reason: 'Token is empty or undefined' }; - } - - if (!isValidGitHubToken(token)) { - return { valid: false, reason: 'Invalid GitHub token format' }; - } - - if (token.length < 36) { - return { valid: false, reason: 'Token is too short' }; - } - - return { valid: true }; -} - -export function getNextToken(tokens: string[]): { token: string; index: number } | null { - if (!Array.isArray(tokens) || tokens.length === 0) { - return null; - } - - const validTokens = tokens.filter((t) => validateTokenBeforeUse(t).valid); - if (validTokens.length === 0) { - throw new Error('No valid GitHub tokens available'); - } - - const currentIndex = Math.floor(Math.random() * validTokens.length); - return { - token: validTokens[currentIndex], - index: tokens.indexOf(validTokens[currentIndex]), - }; -} From 5d07a87594be5d356d76a774a9836becb04e25ae Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Sat, 6 Jun 2026 13:54:59 +0530 Subject: [PATCH 3/5] Update tests to use valid-format mock GitHub tokens getGitHubTokens() now filters tokens by format (ghp_/ghu_/ghs_/ghr_/ github_pat_ prefix and length >= 36). Update test fixtures in github.rotation.test.ts and github.test.ts to use properly formatted mock tokens so the existing rotation and auth tests continue to pass. --- lib/github.rotation.test.ts | 20 +++++++++++++------- lib/github.test.ts | 8 ++++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/github.rotation.test.ts b/lib/github.rotation.test.ts index 44ae5950f..f5cd7cc3f 100644 --- a/lib/github.rotation.test.ts +++ b/lib/github.rotation.test.ts @@ -7,6 +7,12 @@ import { getGlobalCircuitBreakerOpenUntilForTests, } from './github'; +const MOCK_TOKEN_1 = 'ghp_token1AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_TOKEN_2 = 'ghp_token2AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_TOKEN_3 = 'ghp_token3AAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_BAD_TOKEN = 'ghp_badtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MOCK_GOOD_TOKEN = 'ghp_goodtokenAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + describe('GitHub Multi-Token Rotation & Fallback', () => { const originalGitHubPat = process.env.GITHUB_PAT; const originalGitHubToken = process.env.GITHUB_TOKEN; @@ -25,15 +31,15 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { }); it('correctly parses multiple comma-separated tokens', () => { - process.env.GITHUB_PAT = ' token1, token2, token3 '; + process.env.GITHUB_PAT = ` ${MOCK_TOKEN_1}, ${MOCK_TOKEN_2}, ${MOCK_TOKEN_3} `; delete process.env.GITHUB_TOKEN; const tokens = getGitHubTokens(); - expect(tokens).toEqual(['token1', 'token2', 'token3']); + expect(tokens).toEqual([MOCK_TOKEN_1, MOCK_TOKEN_2, MOCK_TOKEN_3]); }); it('rotates to the next token on HTTP 429 rate limiting', async () => { - process.env.GITHUB_PAT = 'token1,token2'; + process.env.GITHUB_PAT = `${MOCK_TOKEN_1},${MOCK_TOKEN_2}`; delete process.env.GITHUB_TOKEN; fetchMock.mockResolvedValueOnce({ @@ -60,14 +66,14 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { expect(fetchMock).toHaveBeenCalledTimes(2); const firstCallHeaders = fetchMock.mock.calls[0][1].headers; - expect(firstCallHeaders.Authorization).toBe('bearer token1'); + expect(firstCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_1}`); const secondCallHeaders = fetchMock.mock.calls[1][1].headers; - expect(secondCallHeaders.Authorization).toBe('bearer token2'); + expect(secondCallHeaders.Authorization).toBe(`bearer ${MOCK_TOKEN_2}`); }); it('rotates to the next token on HTTP 401 unauthorized and excludes the bad token for 24h', async () => { - process.env.GITHUB_PAT = 'bad_token,good_token'; + process.env.GITHUB_PAT = `${MOCK_BAD_TOKEN},${MOCK_GOOD_TOKEN}`; delete process.env.GITHUB_TOKEN; fetchMock.mockResolvedValueOnce({ @@ -104,7 +110,7 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { }); expect(res2.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('bearer good_token'); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`); }); it('prioritizes token with highest remaining quota', async () => { diff --git a/lib/github.test.ts b/lib/github.test.ts index 211a3e027..e7ac9a8eb 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -52,7 +52,7 @@ function mockResponse(body: unknown, status = 200): Response { beforeEach(() => { clearGitHubApiCacheForTests(); - process.env.GITHUB_PAT = 'test-token'; + process.env.GITHUB_PAT = 'ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; delete process.env.GITHUB_TOKEN; }); @@ -177,7 +177,7 @@ describe('fetchGitHubContributions', () => { expect(url).toBe('https://api.github.com/graphql'); expect(options?.method).toBe('POST'); expect(options?.headers).toMatchObject({ - Authorization: 'bearer test-token', + Authorization: 'bearer ghp_testtokenAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'Content-Type': 'application/json', }); @@ -188,7 +188,7 @@ describe('fetchGitHubContributions', () => { it('uses GITHUB_TOKEN when GITHUB_PAT is not configured', async () => { delete process.env.GITHUB_PAT; - process.env.GITHUB_TOKEN = 'actions-token'; + process.env.GITHUB_TOKEN = 'ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA'; vi.mocked(fetch).mockResolvedValue( mockResponse({ data: { @@ -206,7 +206,7 @@ describe('fetchGitHubContributions', () => { const [, options] = vi.mocked(fetch).mock.calls[0]; expect(options?.headers).toMatchObject({ - Authorization: 'bearer actions-token', + Authorization: 'bearer ghp_actionstokenAAAAAAAAAAAAAAAAAAAAAAAA', }); }); From 65c5219f32b9618fc10ab9fac5ec8791c53e9006 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Fri, 12 Jun 2026 20:11:02 +0530 Subject: [PATCH 4/5] fix: correct test token expectations to use mock constants instead of hardcoded strings --- lib/github.rotation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/github.rotation.test.ts b/lib/github.rotation.test.ts index f5cd7cc3f..83e0e35d5 100644 --- a/lib/github.rotation.test.ts +++ b/lib/github.rotation.test.ts @@ -95,8 +95,8 @@ describe('GitHub Multi-Token Rotation & Fallback', () => { expect(res.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('bearer bad_token'); - expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('bearer good_token'); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`bearer ${MOCK_BAD_TOKEN}`); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(`bearer ${MOCK_GOOD_TOKEN}`); fetchMock.mockResolvedValueOnce({ status: 200, From 3fbb8fb89c997069b85224f184c946caa595def8 Mon Sep 17 00:00:00 2001 From: Anshul Jain Date: Mon, 15 Jun 2026 00:38:05 +0530 Subject: [PATCH 5/5] test: update mock token to valid GitHub format in token fallback test Replace 'my-actions-token' with 'ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA' to match the GitHub token format validation requirements (must start with ghp_, ghu_, ghs_, ghr_, or github_pat_ and be at least 36 characters). This ensures the test validates the correct behavior while complying with the token validation rules. --- lib/github.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/github.test.ts b/lib/github.test.ts index e7ac9a8eb..5dc9cc47d 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -212,7 +212,7 @@ describe('fetchGitHubContributions', () => { it('verifies Authorization header uses GITHUB_TOKEN value in fallback path', async () => { delete process.env.GITHUB_PAT; - process.env.GITHUB_TOKEN = 'my-actions-token'; + process.env.GITHUB_TOKEN = 'ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA'; vi.mocked(fetch).mockResolvedValue( mockResponse({ data: { @@ -230,7 +230,7 @@ describe('fetchGitHubContributions', () => { const [, options] = vi.mocked(fetch).mock.calls[0]; expect(options?.headers).toMatchObject({ - Authorization: 'bearer my-actions-token', + Authorization: 'bearer ghp_fallbacktokenAAAAAAAAAAAAAAAAAAAAAAAA', }); });