diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts index 76fe4e1..2649662 100644 --- a/__tests__/util.test.ts +++ b/__tests__/util.test.ts @@ -1,5 +1,7 @@ import * as config from '../src/internal/shared/config' import * as util from '../src/internal/shared/util' +import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util' +import {setSecret, debug} from '@actions/core' export const testRuntimeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhOmNhMzk1MDg1LTA0MGEtNTI2Yi0yY2U4LWJkYzg1ZjY5Mjc3NCIsImlhdCI6MTUxNjIzOTAyMn0.XYnI_wHPBlUi1mqYveJnnkJhp4dlFjqxzRmISPsqfw8' @@ -59,3 +61,159 @@ describe('get-backend-ids-from-token', () => { ) }) }) + +jest.mock('@actions/core') + +describe('maskSigUrl', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does nothing if no sig parameter is present', () => { + const url = 'https://example.com' + maskSigUrl(url) + expect(setSecret).not.toHaveBeenCalled() + }) + + it('masks the sig parameter in the middle of the URL and sets it as a secret', () => { + const url = 'https://example.com/?param1=value1&sig=12345¶m2=value2' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('12345') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('12345')) + }) + + it('does nothing if the URL is empty', () => { + const url = '' + maskSigUrl(url) + expect(setSecret).not.toHaveBeenCalled() + }) + + it('handles URLs with fragments', () => { + const url = 'https://example.com?sig=12345#fragment' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('12345') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('12345')) + }) +}) + +describe('maskSigUrl handles special characters in signatures', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('handles signatures with slashes', () => { + const url = 'https://example.com/?sig=abc/123' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('abc/123') + expect(setSecret).toHaveBeenCalledWith('abc%2F123') + }) + + it('handles signatures with plus signs', () => { + const url = 'https://example.com/?sig=abc+123' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('abc 123') + expect(setSecret).toHaveBeenCalledWith('abc%20123') + }) + + it('handles signatures with equals signs', () => { + const url = 'https://example.com/?sig=abc=123' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('abc=123') + expect(setSecret).toHaveBeenCalledWith('abc%3D123') + }) + + it('handles already percent-encoded signatures', () => { + const url = 'https://example.com/?sig=abc%2F123%3D' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('abc/123=') + expect(setSecret).toHaveBeenCalledWith('abc%2F123%3D') + }) + + it('handles complex Azure SAS signatures', () => { + const url = + 'https://example.com/container/file.txt?sig=nXyQIUj%2F%2F06Cxt80pBRYiiJlYqtPYg5sz%2FvEh5iHAhw%3D&se=2023-12-31' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith( + 'nXyQIUj//06Cxt80pBRYiiJlYqtPYg5sz/vEh5iHAhw=' + ) + expect(setSecret).toHaveBeenCalledWith( + 'nXyQIUj%2F%2F06Cxt80pBRYiiJlYqtPYg5sz%2FvEh5iHAhw%3D' + ) + }) + + it('handles signatures with multiple special characters', () => { + const url = 'https://example.com/?sig=a/b+c=d&e=f' + maskSigUrl(url) + expect(setSecret).toHaveBeenCalledWith('a/b c=d') + expect(setSecret).toHaveBeenCalledWith('a%2Fb%20c%3Dd') + }) +}) + +describe('maskSecretUrls', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('masks sig parameters in signed_upload_url and signed_url', () => { + const body = { + signed_upload_url: 'https://upload.com?sig=upload123', + signed_url: 'https://download.com?sig=download123' + } + maskSecretUrls(body) + expect(setSecret).toHaveBeenCalledWith('upload123') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('upload123')) + expect(setSecret).toHaveBeenCalledWith('download123') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('download123')) + }) + + it('handles case where only upload_url is present', () => { + const body = { + signed_upload_url: 'https://upload.com?sig=upload123' + } + maskSecretUrls(body) + expect(setSecret).toHaveBeenCalledWith('upload123') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('upload123')) + }) + + it('handles case where only download_url is present', () => { + const body = { + signed_url: 'https://download.com?sig=download123' + } + maskSecretUrls(body) + expect(setSecret).toHaveBeenCalledWith('download123') + expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('download123')) + }) + + it('handles case where URLs do not contain sig parameters', () => { + const body = { + signed_upload_url: 'https://upload.com?token=abc', + signed_url: 'https://download.com?token=xyz' + } + maskSecretUrls(body) + expect(setSecret).not.toHaveBeenCalled() + }) + + it('handles empty string URLs', () => { + const body = { + signed_upload_url: '', + signed_url: '' + } + maskSecretUrls(body) + expect(setSecret).not.toHaveBeenCalled() + }) + + it('does nothing if body is not an object or is null', () => { + maskSecretUrls(null) + expect(debug).toHaveBeenCalledWith('body is not an object or is null') + expect(setSecret).not.toHaveBeenCalled() + }) + + it('does nothing if signed_upload_url and signed_url are not strings', () => { + const body = { + signed_upload_url: 123, + signed_url: 456 + } + maskSecretUrls(body) + expect(setSecret).not.toHaveBeenCalled() + }) +}) diff --git a/src/internal/shared/artifact-twirp-client.ts b/src/internal/shared/artifact-twirp-client.ts index 00c65bc..5749912 100644 --- a/src/internal/shared/artifact-twirp-client.ts +++ b/src/internal/shared/artifact-twirp-client.ts @@ -5,6 +5,7 @@ import {ArtifactServiceClientJSON} from '../../generated' import {getResultsServiceUrl, getRuntimeToken} from './config' import {getUserAgentString} from './user-agent' import {NetworkError, UsageError} from './errors' +import {maskSecretUrls} from './util' // The twirp http client must implement this interface interface Rpc { @@ -86,6 +87,7 @@ class ArtifactHttpClient implements Rpc { debug(`[Response] - ${response.message.statusCode}`) debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`) const body = JSON.parse(rawBody) + maskSecretUrls(body) debug(`Body: ${JSON.stringify(body, null, 2)}`) if (this.isSuccessStatusCode(statusCode)) { return {response, body} diff --git a/src/internal/shared/util.ts b/src/internal/shared/util.ts index 07392b3..67120e2 100644 --- a/src/internal/shared/util.ts +++ b/src/internal/shared/util.ts @@ -1,6 +1,7 @@ import * as core from '@actions/core' import {getRuntimeToken} from './config' import jwt_decode from 'jwt-decode' +import {debug, setSecret} from '@actions/core' export interface BackendIds { workflowRunBackendId: string @@ -69,3 +70,76 @@ export function getBackendIdsFromToken(): BackendIds { throw InvalidJwtError } + +/** + * Masks the `sig` parameter in a URL and sets it as a secret. + * + * @param url - The URL containing the signature parameter to mask + * @remarks + * This function attempts to parse the provided URL and identify the 'sig' query parameter. + * If found, it registers both the raw and URL-encoded signature values as secrets using + * the Actions `setSecret` API, which prevents them from being displayed in logs. + * + * The function handles errors gracefully if URL parsing fails, logging them as debug messages. + * + * @example + * ```typescript + * // Mask a signature in an Azure SAS token URL + * maskSigUrl('https://example.blob.core.windows.net/container/file.txt?sig=abc123&se=2023-01-01'); + * ``` + */ +export function maskSigUrl(url: string): void { + if (!url) return + try { + const parsedUrl = new URL(url) + const signature = parsedUrl.searchParams.get('sig') + if (signature) { + setSecret(signature) + setSecret(encodeURIComponent(signature)) + } + } catch (error) { + debug( + `Failed to parse URL: ${url} ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +/** + * Masks sensitive information in URLs containing signature parameters. + * Currently supports masking 'sig' parameters in the 'signed_upload_url' + * and 'signed_download_url' properties of the provided object. + * + * @param body - The object should contain a signature + * @remarks + * This function extracts URLs from the object properties and calls maskSigUrl + * on each one to redact sensitive signature information. The function doesn't + * modify the original object; it only marks the signatures as secrets for + * logging purposes. + * + * @example + * ```typescript + * const responseBody = { + * signed_upload_url: 'https://example.com?sig=abc123', + * signed_download_url: 'https://example.com?sig=def456' + * }; + * maskSecretUrls(responseBody); + * ``` + */ +export function maskSecretUrls(body: Record | null): void { + if (typeof body !== 'object' || body === null) { + debug('body is not an object or is null') + return + } + + if ( + 'signed_upload_url' in body && + typeof body.signed_upload_url === 'string' + ) { + maskSigUrl(body.signed_upload_url) + } + if ('signed_url' in body && typeof body.signed_url === 'string') { + maskSigUrl(body.signed_url) + } +}