Merge pull request #1982 from actions/salmanmkc/obfuscate-sas

Remove logging of any SAS tokens in Actions/Cache and Actions/Artifact
This commit is contained in:
Salman Chishti 2025-03-17 11:47:29 +00:00 committed by GitHub
commit 2559a2ac8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 549 additions and 11 deletions

View File

@ -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&param2=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()
})
})

View File

@ -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}

View File

@ -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<string, unknown> | 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)
}
}

158
packages/cache/__tests__/util.test.ts vendored Normal file
View File

@ -0,0 +1,158 @@
import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util'
import {setSecret, debug} from '@actions/core'
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&param2=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_download_url', () => {
const body = {
signed_upload_url: 'https://upload.com?sig=upload123',
signed_download_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_download_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_download_url: 'https://download.com?token=xyz'
}
maskSecretUrls(body)
expect(setSecret).not.toHaveBeenCalled()
})
it('handles empty string URLs', () => {
const body = {
signed_upload_url: '',
signed_download_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_download_url are not strings', () => {
const body = {
signed_upload_url: 123,
signed_download_url: 456
}
maskSecretUrls(body)
expect(setSecret).not.toHaveBeenCalled()
})
})

31
packages/cache/package-lock.json generated vendored
View File

@ -21,6 +21,7 @@
"semver": "^6.3.1"
},
"devDependencies": {
"@types/node": "^22.13.9",
"@types/semver": "^6.0.0",
"typescript": "^5.2.2"
}
@ -324,9 +325,13 @@
}
},
"node_modules/@types/node": {
"version": "20.4.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.6.tgz",
"integrity": "sha512-q0RkvNgMweWWIvSMDiXhflGUKMdIxBo2M2tYM/0kEGDueQByFzK4KZAgu5YHGFNxziTlppNpTIBcqHQAxlfHdA=="
"version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.4",
@ -548,6 +553,12 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -824,9 +835,12 @@
}
},
"@types/node": {
"version": "20.4.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.6.tgz",
"integrity": "sha512-q0RkvNgMweWWIvSMDiXhflGUKMdIxBo2M2tYM/0kEGDueQByFzK4KZAgu5YHGFNxziTlppNpTIBcqHQAxlfHdA=="
"version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"requires": {
"undici-types": "~6.20.0"
}
},
"@types/node-fetch": {
"version": "2.6.4",
@ -993,6 +1007,11 @@
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"dev": true
},
"undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@ -49,6 +49,7 @@
"semver": "^6.3.1"
},
"devDependencies": {
"@types/node": "^22.13.9",
"@types/semver": "^6.0.0",
"typescript": "^5.2.2"
}

View File

@ -6,6 +6,7 @@ import {getRuntimeToken} from '../cacheUtils'
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client'
import {CacheServiceClientJSON} from '../../generated/results/api/v1/cache.twirp-client'
import {maskSecretUrls} from './util'
// The twirp http client must implement this interface
interface Rpc {
@ -94,6 +95,7 @@ class CacheServiceClient 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}

View File

@ -0,0 +1,76 @@
import {debug, setSecret} from '@actions/core'
/**
* 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://blob.core.windows.net/?sig=abc123',
* signed_download_url: 'https://blob.core/windows.net/?sig=def456'
* };
* maskSecretUrls(responseBody);
* ```
*/
export function maskSecretUrls(body: Record<string, unknown> | 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_download_url' in body &&
typeof body.signed_download_url === 'string'
) {
maskSigUrl(body.signed_download_url)
}
}

View File

@ -11,14 +11,37 @@ export interface CommandProperties {
}
/**
* Commands
* Issues a command to the GitHub Actions runner
*
* @param command - The command name to issue
* @param properties - Additional properties for the command (key-value pairs)
* @param message - The message to include with the command
* @remarks
* This function outputs a specially formatted string to stdout that the Actions
* runner interprets as a command. These commands can control workflow behavior,
* set outputs, create annotations, mask values, and more.
*
* Command Format:
* ::name key=value,key=value::message
*
* Examples:
* ::warning::This is the message
* ::set-env name=MY_VAR::some value
* @example
* ```typescript
* // Issue a warning annotation
* issueCommand('warning', {}, 'This is a warning message');
* // Output: ::warning::This is a warning message
*
* // Set an environment variable
* issueCommand('set-env', { name: 'MY_VAR' }, 'some value');
* // Output: ::set-env name=MY_VAR::some value
*
* // Add a secret mask
* issueCommand('add-mask', {}, 'secretValue123');
* // Output: ::add-mask::secretValue123
* ```
*
* @internal
* This is an internal utility function that powers the public API functions
* such as setSecret, warning, error, and exportVariable.
*/
export function issueCommand(
command: string,

View File

@ -94,7 +94,32 @@ export function exportVariable(name: string, val: any): void {
/**
* Registers a secret which will get masked from logs
* @param secret value of the secret
*
* @param secret - Value of the secret to be masked
* @remarks
* This function instructs the Actions runner to mask the specified value in any
* logs produced during the workflow run. Once registered, the secret value will
* be replaced with asterisks (***) whenever it appears in console output, logs,
* or error messages.
*
* This is useful for protecting sensitive information such as:
* - API keys
* - Access tokens
* - Authentication credentials
* - URL parameters containing signatures (SAS tokens)
*
* Note that masking only affects future logs; any previous appearances of the
* secret in logs before calling this function will remain unmasked.
*
* @example
* ```typescript
* // Register an API token as a secret
* const apiToken = "abc123xyz456";
* setSecret(apiToken);
*
* // Now any logs containing this value will show *** instead
* console.log(`Using token: ${apiToken}`); // Outputs: "Using token: ***"
* ```
*/
export function setSecret(secret: string): void {
issueCommand('add-mask', {}, secret)