1
0
Fork 0

artifact(download): skip non-zip files

pull/1874/head
CrazyMax 2024-11-14 12:19:21 +01:00
parent bb2278e5cf
commit 4ec46bb673
No known key found for this signature in database
GPG Key ID: ADE44D8C9D44FBE4
3 changed files with 80 additions and 14 deletions

View File

@ -104,6 +104,18 @@ const cleanup = async (): Promise<void> => {
const mockGetArtifactSuccess = jest.fn(() => { const mockGetArtifactSuccess = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket()) const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200 message.statusCode = 200
message.headers['content-type'] = 'zip'
message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null)
return {
message
}
})
const mockGetArtifactGzip = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200
message.headers['content-type'] = 'application/gzip'
message.push(fs.readFileSync(fixtures.exampleArtifact.path)) message.push(fs.readFileSync(fixtures.exampleArtifact.path))
message.push(null) message.push(null)
return { return {
@ -124,6 +136,7 @@ const mockGetArtifactFailure = jest.fn(() => {
const mockGetArtifactMalicious = jest.fn(() => { const mockGetArtifactMalicious = jest.fn(() => {
const message = new http.IncomingMessage(new net.Socket()) const message = new http.IncomingMessage(new net.Socket())
message.statusCode = 200 message.statusCode = 200
message.headers['content-type'] = 'zip'
message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts message.push(fs.readFileSync(path.join(__dirname, 'fixtures', 'evil.zip'))) // evil.zip contains files that are formatted x/../../etc/hosts
message.push(null) message.push(null)
return { return {
@ -178,6 +191,7 @@ describe('download-artifact', () => {
) )
expectExtractedArchive(fixtures.workspaceDir) expectExtractedArchive(fixtures.workspaceDir)
expect(response.downloadPath).toBe(fixtures.workspaceDir) expect(response.downloadPath).toBe(fixtures.workspaceDir)
expect(response.skipped).toBe(false)
}) })
it('should not allow path traversal from malicious artifacts', async () => { it('should not allow path traversal from malicious artifacts', async () => {
@ -231,6 +245,7 @@ describe('download-artifact', () => {
).toBe(true) ).toBe(true)
expect(response.downloadPath).toBe(fixtures.workspaceDir) expect(response.downloadPath).toBe(fixtures.workspaceDir)
expect(response.skipped).toBe(false)
}) })
it('should successfully download an artifact to user defined path', async () => { it('should successfully download an artifact to user defined path', async () => {
@ -280,6 +295,7 @@ describe('download-artifact', () => {
) )
expectExtractedArchive(customPath) expectExtractedArchive(customPath)
expect(response.downloadPath).toBe(customPath) expect(response.downloadPath).toBe(customPath)
expect(response.skipped).toBe(false)
}) })
it('should fail if download artifact API does not respond with location', async () => { it('should fail if download artifact API does not respond with location', async () => {
@ -316,6 +332,7 @@ describe('download-artifact', () => {
// mock http client to delay response data by 30s // mock http client to delay response data by 30s
const msg = new http.IncomingMessage(new net.Socket()) const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 200 msg.statusCode = 200
msg.headers['content-type'] = 'zip'
const mockGet = jest.fn(async () => { const mockGet = jest.fn(async () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -444,7 +461,39 @@ describe('download-artifact', () => {
) )
expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1) expect(mockGetArtifactSuccess).toHaveBeenCalledTimes(1)
expect(response.downloadPath).toBe(fixtures.workspaceDir) expect(response.downloadPath).toBe(fixtures.workspaceDir)
expect(response.skipped).toBe(false)
}, 28000) }, 28000)
it('should skip if artifact does not have the right content type', async () => {
const downloadArtifactMock = github.getOctokit(fixtures.token).rest
.actions.downloadArtifact as MockedDownloadArtifact
downloadArtifactMock.mockResolvedValueOnce({
headers: {
location: fixtures.blobStorageUrl
},
status: 302,
url: '',
data: Buffer.from('')
})
const mockHttpClient = (HttpClient as jest.Mock).mockImplementation(
() => {
return {
get: mockGetArtifactGzip
}
}
)
const response = await downloadArtifactPublic(
fixtures.artifactID,
fixtures.repositoryOwner,
fixtures.repositoryName,
fixtures.token
)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(response.skipped).toBe(true)
})
}) })
describe('internal', () => { describe('internal', () => {
@ -499,6 +548,7 @@ describe('download-artifact', () => {
expectExtractedArchive(fixtures.workspaceDir) expectExtractedArchive(fixtures.workspaceDir)
expect(response.downloadPath).toBe(fixtures.workspaceDir) expect(response.downloadPath).toBe(fixtures.workspaceDir)
expect(response.skipped).toBe(false)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockListArtifacts).toHaveBeenCalledWith({ expect(mockListArtifacts).toHaveBeenCalledWith({
idFilter: { idFilter: {
@ -550,6 +600,7 @@ describe('download-artifact', () => {
expectExtractedArchive(customPath) expectExtractedArchive(customPath)
expect(response.downloadPath).toBe(customPath) expect(response.downloadPath).toBe(customPath)
expect(response.skipped).toBe(false)
expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString()) expect(mockHttpClient).toHaveBeenCalledWith(getUserAgentString())
expect(mockListArtifacts).toHaveBeenCalledWith({ expect(mockListArtifacts).toHaveBeenCalledWith({
idFilter: { idFilter: {

View File

@ -37,12 +37,11 @@ async function exists(path: string): Promise<boolean> {
} }
} }
async function streamExtract(url: string, directory: string): Promise<void> { async function streamExtract(url: string, directory: string): Promise<boolean> {
let retryCount = 0 let retryCount = 0
while (retryCount < 5) { while (retryCount < 5) {
try { try {
await streamExtractExternal(url, directory) return await streamExtractExternal(url, directory)
return
} catch (error) { } catch (error) {
retryCount++ retryCount++
core.debug( core.debug(
@ -59,18 +58,23 @@ async function streamExtract(url: string, directory: string): Promise<void> {
export async function streamExtractExternal( export async function streamExtractExternal(
url: string, url: string,
directory: string directory: string
): Promise<void> { ): Promise<boolean> {
const client = new httpClient.HttpClient(getUserAgentString()) const client = new httpClient.HttpClient(getUserAgentString())
const response = await client.get(url) const response = await client.get(url)
if (response.message.statusCode !== 200) { if (response.message.statusCode !== 200) {
throw new Error( throw new Error(
`Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}` `Unexpected HTTP response from blob storage: ${response.message.statusCode} ${response.message.statusMessage}`
) )
} else if (response.message.headers['content-type'] !== 'zip') {
core.debug(
`Invalid content-type: ${response.message.headers['content-type']}, skipping download`
)
return false
} }
const timeout = 30 * 1000 // 30 seconds const timeout = 30 * 1000 // 30 seconds
return new Promise((resolve, reject) => { return new Promise<boolean>((resolve, reject) => {
const timerFn = (): void => { const timerFn = (): void => {
response.message.destroy( response.message.destroy(
new Error(`Blob storage chunk did not respond in ${timeout}ms`) new Error(`Blob storage chunk did not respond in ${timeout}ms`)
@ -92,7 +96,7 @@ export async function streamExtractExternal(
.pipe(unzip.Extract({path: directory})) .pipe(unzip.Extract({path: directory}))
.on('close', () => { .on('close', () => {
clearTimeout(timer) clearTimeout(timer)
resolve() resolve(true)
}) })
.on('error', (error: Error) => { .on('error', (error: Error) => {
reject(error) reject(error)
@ -140,13 +144,16 @@ export async function downloadArtifactPublic(
try { try {
core.info(`Starting download of artifact to: ${downloadPath}`) core.info(`Starting download of artifact to: ${downloadPath}`)
await streamExtract(location, downloadPath) if (await streamExtract(location, downloadPath)) {
core.info(`Artifact download completed successfully.`) core.info(`Artifact download completed successfully.`)
return {downloadPath, skipped: false}
} else {
core.info(`Artifact download skipped.`)
return {downloadPath, skipped: true}
}
} catch (error) { } catch (error) {
throw new Error(`Unable to download and extract artifact: ${error.message}`) throw new Error(`Unable to download and extract artifact: ${error.message}`)
} }
return {downloadPath}
} }
export async function downloadArtifactInternal( export async function downloadArtifactInternal(
@ -192,13 +199,16 @@ export async function downloadArtifactInternal(
try { try {
core.info(`Starting download of artifact to: ${downloadPath}`) core.info(`Starting download of artifact to: ${downloadPath}`)
await streamExtract(signedUrl, downloadPath) if (await streamExtract(signedUrl, downloadPath)) {
core.info(`Artifact download completed successfully.`) core.info(`Artifact download completed successfully.`)
return {downloadPath, skipped: false}
} else {
core.info(`Artifact download skipped.`)
return {downloadPath, skipped: true}
}
} catch (error) { } catch (error) {
throw new Error(`Unable to download and extract artifact: ${error.message}`) throw new Error(`Unable to download and extract artifact: ${error.message}`)
} }
return {downloadPath}
} }
async function resolveOrCreateDirectory( async function resolveOrCreateDirectory(

View File

@ -86,6 +86,11 @@ export interface DownloadArtifactResponse {
* The path where the artifact was downloaded to * The path where the artifact was downloaded to
*/ */
downloadPath?: string downloadPath?: string
/**
* If the artifact download was skipped
*/
skipped?: boolean
} }
/** /**