Skip to content

Commit 2559a2a

Browse files
authored
Merge pull request #1982 from actions/salmanmkc/obfuscate-sas
Remove logging of any SAS tokens in Actions/Cache and Actions/Artifact
2 parents 253e837 + 957d42e commit 2559a2a

File tree

10 files changed

+549
-11
lines changed

10 files changed

+549
-11
lines changed

packages/artifact/__tests__/util.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as config from '../src/internal/shared/config'
22
import * as util from '../src/internal/shared/util'
3+
import {maskSigUrl, maskSecretUrls} from '../src/internal/shared/util'
4+
import {setSecret, debug} from '@actions/core'
35

46
export const testRuntimeToken =
57
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhOmNhMzk1MDg1LTA0MGEtNTI2Yi0yY2U4LWJkYzg1ZjY5Mjc3NCIsImlhdCI6MTUxNjIzOTAyMn0.XYnI_wHPBlUi1mqYveJnnkJhp4dlFjqxzRmISPsqfw8'
@@ -59,3 +61,159 @@ describe('get-backend-ids-from-token', () => {
5961
)
6062
})
6163
})
64+
65+
jest.mock('@actions/core')
66+
67+
describe('maskSigUrl', () => {
68+
beforeEach(() => {
69+
jest.clearAllMocks()
70+
})
71+
72+
it('does nothing if no sig parameter is present', () => {
73+
const url = 'https://example.com'
74+
maskSigUrl(url)
75+
expect(setSecret).not.toHaveBeenCalled()
76+
})
77+
78+
it('masks the sig parameter in the middle of the URL and sets it as a secret', () => {
79+
const url = 'https://example.com/?param1=value1&sig=12345&param2=value2'
80+
maskSigUrl(url)
81+
expect(setSecret).toHaveBeenCalledWith('12345')
82+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('12345'))
83+
})
84+
85+
it('does nothing if the URL is empty', () => {
86+
const url = ''
87+
maskSigUrl(url)
88+
expect(setSecret).not.toHaveBeenCalled()
89+
})
90+
91+
it('handles URLs with fragments', () => {
92+
const url = 'https://example.com?sig=12345#fragment'
93+
maskSigUrl(url)
94+
expect(setSecret).toHaveBeenCalledWith('12345')
95+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('12345'))
96+
})
97+
})
98+
99+
describe('maskSigUrl handles special characters in signatures', () => {
100+
beforeEach(() => {
101+
jest.clearAllMocks()
102+
})
103+
104+
it('handles signatures with slashes', () => {
105+
const url = 'https://example.com/?sig=abc/123'
106+
maskSigUrl(url)
107+
expect(setSecret).toHaveBeenCalledWith('abc/123')
108+
expect(setSecret).toHaveBeenCalledWith('abc%2F123')
109+
})
110+
111+
it('handles signatures with plus signs', () => {
112+
const url = 'https://example.com/?sig=abc+123'
113+
maskSigUrl(url)
114+
expect(setSecret).toHaveBeenCalledWith('abc 123')
115+
expect(setSecret).toHaveBeenCalledWith('abc%20123')
116+
})
117+
118+
it('handles signatures with equals signs', () => {
119+
const url = 'https://example.com/?sig=abc=123'
120+
maskSigUrl(url)
121+
expect(setSecret).toHaveBeenCalledWith('abc=123')
122+
expect(setSecret).toHaveBeenCalledWith('abc%3D123')
123+
})
124+
125+
it('handles already percent-encoded signatures', () => {
126+
const url = 'https://example.com/?sig=abc%2F123%3D'
127+
maskSigUrl(url)
128+
expect(setSecret).toHaveBeenCalledWith('abc/123=')
129+
expect(setSecret).toHaveBeenCalledWith('abc%2F123%3D')
130+
})
131+
132+
it('handles complex Azure SAS signatures', () => {
133+
const url =
134+
'https://example.com/container/file.txt?sig=nXyQIUj%2F%2F06Cxt80pBRYiiJlYqtPYg5sz%2FvEh5iHAhw%3D&se=2023-12-31'
135+
maskSigUrl(url)
136+
expect(setSecret).toHaveBeenCalledWith(
137+
'nXyQIUj//06Cxt80pBRYiiJlYqtPYg5sz/vEh5iHAhw='
138+
)
139+
expect(setSecret).toHaveBeenCalledWith(
140+
'nXyQIUj%2F%2F06Cxt80pBRYiiJlYqtPYg5sz%2FvEh5iHAhw%3D'
141+
)
142+
})
143+
144+
it('handles signatures with multiple special characters', () => {
145+
const url = 'https://example.com/?sig=a/b+c=d&e=f'
146+
maskSigUrl(url)
147+
expect(setSecret).toHaveBeenCalledWith('a/b c=d')
148+
expect(setSecret).toHaveBeenCalledWith('a%2Fb%20c%3Dd')
149+
})
150+
})
151+
152+
describe('maskSecretUrls', () => {
153+
beforeEach(() => {
154+
jest.clearAllMocks()
155+
})
156+
157+
it('masks sig parameters in signed_upload_url and signed_url', () => {
158+
const body = {
159+
signed_upload_url: 'https://upload.com?sig=upload123',
160+
signed_url: 'https://download.com?sig=download123'
161+
}
162+
maskSecretUrls(body)
163+
expect(setSecret).toHaveBeenCalledWith('upload123')
164+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('upload123'))
165+
expect(setSecret).toHaveBeenCalledWith('download123')
166+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('download123'))
167+
})
168+
169+
it('handles case where only upload_url is present', () => {
170+
const body = {
171+
signed_upload_url: 'https://upload.com?sig=upload123'
172+
}
173+
maskSecretUrls(body)
174+
expect(setSecret).toHaveBeenCalledWith('upload123')
175+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('upload123'))
176+
})
177+
178+
it('handles case where only download_url is present', () => {
179+
const body = {
180+
signed_url: 'https://download.com?sig=download123'
181+
}
182+
maskSecretUrls(body)
183+
expect(setSecret).toHaveBeenCalledWith('download123')
184+
expect(setSecret).toHaveBeenCalledWith(encodeURIComponent('download123'))
185+
})
186+
187+
it('handles case where URLs do not contain sig parameters', () => {
188+
const body = {
189+
signed_upload_url: 'https://upload.com?token=abc',
190+
signed_url: 'https://download.com?token=xyz'
191+
}
192+
maskSecretUrls(body)
193+
expect(setSecret).not.toHaveBeenCalled()
194+
})
195+
196+
it('handles empty string URLs', () => {
197+
const body = {
198+
signed_upload_url: '',
199+
signed_url: ''
200+
}
201+
maskSecretUrls(body)
202+
expect(setSecret).not.toHaveBeenCalled()
203+
})
204+
205+
it('does nothing if body is not an object or is null', () => {
206+
maskSecretUrls(null)
207+
expect(debug).toHaveBeenCalledWith('body is not an object or is null')
208+
expect(setSecret).not.toHaveBeenCalled()
209+
})
210+
211+
it('does nothing if signed_upload_url and signed_url are not strings', () => {
212+
const body = {
213+
signed_upload_url: 123,
214+
signed_url: 456
215+
}
216+
maskSecretUrls(body)
217+
expect(setSecret).not.toHaveBeenCalled()
218+
})
219+
})

packages/artifact/src/internal/shared/artifact-twirp-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {ArtifactServiceClientJSON} from '../../generated'
55
import {getResultsServiceUrl, getRuntimeToken} from './config'
66
import {getUserAgentString} from './user-agent'
77
import {NetworkError, UsageError} from './errors'
8+
import {maskSecretUrls} from './util'
89

910
// The twirp http client must implement this interface
1011
interface Rpc {
@@ -86,6 +87,7 @@ class ArtifactHttpClient implements Rpc {
8687
debug(`[Response] - ${response.message.statusCode}`)
8788
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
8889
const body = JSON.parse(rawBody)
90+
maskSecretUrls(body)
8991
debug(`Body: ${JSON.stringify(body, null, 2)}`)
9092
if (this.isSuccessStatusCode(statusCode)) {
9193
return {response, body}

packages/artifact/src/internal/shared/util.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as core from '@actions/core'
22
import {getRuntimeToken} from './config'
33
import jwt_decode from 'jwt-decode'
4+
import {debug, setSecret} from '@actions/core'
45

56
export interface BackendIds {
67
workflowRunBackendId: string
@@ -69,3 +70,76 @@ export function getBackendIdsFromToken(): BackendIds {
6970

7071
throw InvalidJwtError
7172
}
73+
74+
/**
75+
* Masks the `sig` parameter in a URL and sets it as a secret.
76+
*
77+
* @param url - The URL containing the signature parameter to mask
78+
* @remarks
79+
* This function attempts to parse the provided URL and identify the 'sig' query parameter.
80+
* If found, it registers both the raw and URL-encoded signature values as secrets using
81+
* the Actions `setSecret` API, which prevents them from being displayed in logs.
82+
*
83+
* The function handles errors gracefully if URL parsing fails, logging them as debug messages.
84+
*
85+
* @example
86+
* ```typescript
87+
* // Mask a signature in an Azure SAS token URL
88+
* maskSigUrl('https://example.blob.core.windows.net/container/file.txt?sig=abc123&se=2023-01-01');
89+
* ```
90+
*/
91+
export function maskSigUrl(url: string): void {
92+
if (!url) return
93+
try {
94+
const parsedUrl = new URL(url)
95+
const signature = parsedUrl.searchParams.get('sig')
96+
if (signature) {
97+
setSecret(signature)
98+
setSecret(encodeURIComponent(signature))
99+
}
100+
} catch (error) {
101+
debug(
102+
`Failed to parse URL: ${url} ${
103+
error instanceof Error ? error.message : String(error)
104+
}`
105+
)
106+
}
107+
}
108+
109+
/**
110+
* Masks sensitive information in URLs containing signature parameters.
111+
* Currently supports masking 'sig' parameters in the 'signed_upload_url'
112+
* and 'signed_download_url' properties of the provided object.
113+
*
114+
* @param body - The object should contain a signature
115+
* @remarks
116+
* This function extracts URLs from the object properties and calls maskSigUrl
117+
* on each one to redact sensitive signature information. The function doesn't
118+
* modify the original object; it only marks the signatures as secrets for
119+
* logging purposes.
120+
*
121+
* @example
122+
* ```typescript
123+
* const responseBody = {
124+
* signed_upload_url: 'https://example.com?sig=abc123',
125+
* signed_download_url: 'https://example.com?sig=def456'
126+
* };
127+
* maskSecretUrls(responseBody);
128+
* ```
129+
*/
130+
export function maskSecretUrls(body: Record<string, unknown> | null): void {
131+
if (typeof body !== 'object' || body === null) {
132+
debug('body is not an object or is null')
133+
return
134+
}
135+
136+
if (
137+
'signed_upload_url' in body &&
138+
typeof body.signed_upload_url === 'string'
139+
) {
140+
maskSigUrl(body.signed_upload_url)
141+
}
142+
if ('signed_url' in body && typeof body.signed_url === 'string') {
143+
maskSigUrl(body.signed_url)
144+
}
145+
}

0 commit comments

Comments
 (0)