mirror of
https://github.com/actions/toolkit.git
synced 2026-08-07 00:00:18 +02:00
- Added a new test suite for parser-differential bypass detection in tar archives. - Introduced length-correct PAX extended-header re-parsing to catch discrepancies between node-tar and system tar. - Enhanced the listAndValidate function to return approved names alongside violations for better extraction control. - Implemented checks for unsafe characters and glob metacharacters in entry paths. - Updated the tar extraction logic to utilize an allow-list for approved entries when path validation is in error mode. - Added utility functions for writing temporary allow-list files for system tar extraction.
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
import * as exec from '@actions/exec'
|
|
import * as core from '@actions/core'
|
|
import * as io from '@actions/io'
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import {CompressionMethod} from '../src/internal/constants'
|
|
import * as tar from '../src/internal/tar'
|
|
import {CacheIntegrityError} from '../src/internal/cacheIntegrityError'
|
|
import * as listAndValidate from '../src/internal/listAndValidate'
|
|
|
|
jest.mock('@actions/exec')
|
|
jest.mock('@actions/io')
|
|
jest.mock('../src/internal/listAndValidate')
|
|
|
|
function getTempDir(): string {
|
|
return path.join(__dirname, '_temp', 'tarPathValidation')
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
|
process.env['GITHUB_WORKSPACE'] = process.cwd()
|
|
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
|
})
|
|
|
|
beforeEach(() => {
|
|
jest.restoreAllMocks()
|
|
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
|
})
|
|
|
|
afterAll(async () => {
|
|
delete process.env['GITHUB_WORKSPACE']
|
|
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
|
})
|
|
|
|
const archive = 'cache.tar.gz'
|
|
|
|
describe('extractTar path validation integration', () => {
|
|
describe("mode 'off' (default)", () => {
|
|
test('does not call listAndValidate when no options passed', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip)
|
|
|
|
expect(listMock).not.toHaveBeenCalled()
|
|
expect(execMock).toHaveBeenCalledTimes(1) // system tar still runs
|
|
})
|
|
|
|
test("explicit pathValidation='off' skips validation", async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'off'
|
|
})
|
|
|
|
expect(listMock).not.toHaveBeenCalled()
|
|
expect(execMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe("mode 'warn'", () => {
|
|
test('clean archive: no warnings emitted, extraction proceeds', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
expect(execMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('violations present: exactly one warning, debug per violation, extraction still proceeds', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
|
approvedNames: [],
|
|
violations: [
|
|
{
|
|
path: '../escape.txt',
|
|
resolved: '',
|
|
entryType: 'File',
|
|
code: 'OUTSIDE_ROOTS',
|
|
reason: 'escapes allowed roots'
|
|
},
|
|
{
|
|
path: 'cache/link',
|
|
linkpath: '/etc/passwd',
|
|
resolved: '',
|
|
entryType: 'SymbolicLink',
|
|
code: 'LINK_OUTSIDE_ROOTS',
|
|
reason: 'symlink target outside allowed roots'
|
|
}
|
|
]
|
|
})
|
|
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
|
const debugSpy = jest.spyOn(core, 'debug').mockImplementation()
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
// Exactly one summary warning
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy.mock.calls[0][0]).toMatch(/2 entries/)
|
|
expect(warnSpy.mock.calls[0][0]).not.toMatch(/failed integrity/)
|
|
// One debug entry per violation
|
|
expect(debugSpy).toHaveBeenCalledTimes(2)
|
|
expect(debugSpy.mock.calls[0][0]).toMatch(/path-validation/)
|
|
// Extraction still happens
|
|
expect(execMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('single violation: warning text uses singular wording', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
|
approvedNames: [],
|
|
violations: [
|
|
{
|
|
path: '../boom.txt',
|
|
resolved: '',
|
|
entryType: 'File',
|
|
code: 'OUTSIDE_ROOTS',
|
|
reason: 'escapes'
|
|
}
|
|
]
|
|
})
|
|
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
|
jest.spyOn(core, 'debug').mockImplementation()
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(warnSpy.mock.calls[0][0]).toMatch(/1 entry/)
|
|
})
|
|
})
|
|
|
|
describe("mode 'error'", () => {
|
|
test('violations present: throws CacheIntegrityError, system tar NEVER invoked', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
|
approvedNames: [],
|
|
violations: [
|
|
{
|
|
path: '../etc/passwd',
|
|
resolved: '',
|
|
entryType: 'File',
|
|
code: 'OUTSIDE_ROOTS',
|
|
reason: 'escapes allowed roots'
|
|
}
|
|
]
|
|
})
|
|
jest.spyOn(core, 'warning').mockImplementation()
|
|
jest.spyOn(core, 'debug').mockImplementation()
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
|
|
|
await expect(
|
|
tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'error'
|
|
})
|
|
).rejects.toThrow(CacheIntegrityError)
|
|
|
|
// The critical security assertion: no extraction directory was created
|
|
// and system tar was never invoked.
|
|
expect(execMock).not.toHaveBeenCalled()
|
|
expect(mkdirMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('thrown error has code PATH_VIOLATION and exposes violations', async () => {
|
|
const violations = [
|
|
{
|
|
path: '../boom.txt',
|
|
resolved: '',
|
|
entryType: 'File' as const,
|
|
code: 'OUTSIDE_ROOTS' as const,
|
|
reason: 'escapes'
|
|
}
|
|
]
|
|
jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue({violations, approvedNames: []})
|
|
jest.spyOn(core, 'warning').mockImplementation()
|
|
jest.spyOn(core, 'debug').mockImplementation()
|
|
|
|
try {
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'error'
|
|
})
|
|
fail('expected CacheIntegrityError')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(CacheIntegrityError)
|
|
const e = err as CacheIntegrityError
|
|
expect(e.code).toBe('PATH_VIOLATION')
|
|
expect(e.violations).toEqual(violations)
|
|
expect(e.message).toMatch(/Refusing to extract/)
|
|
}
|
|
})
|
|
|
|
test('clean archive: no throw, extraction proceeds normally', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([])
|
|
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'error'
|
|
})
|
|
|
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
expect(execMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('listAndValidate throws parse error: wrapped as CacheIntegrityError(PARSE_ERROR), no extraction', async () => {
|
|
jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockRejectedValue(new Error('tar parse error (TAR_BAD_ARCHIVE): bad'))
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
|
|
|
try {
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'error'
|
|
})
|
|
fail('expected throw')
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(CacheIntegrityError)
|
|
expect((err as CacheIntegrityError).code).toBe('PARSE_ERROR')
|
|
expect((err as CacheIntegrityError).message).toMatch(/tar parse error/)
|
|
}
|
|
expect(execMock).not.toHaveBeenCalled()
|
|
expect(mkdirMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test("listAndValidate parse failure in 'warn' mode: logs warning, skips validation, extraction proceeds", async () => {
|
|
jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockRejectedValue(new Error('bad bytes'))
|
|
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
|
|
|
// In 'warn' mode a parse failure is non-fatal: the validator's tar
|
|
// parser is stricter than the system `tar` that performs the actual
|
|
// extraction, so the archive may still extract cleanly. We log a
|
|
// warning and proceed.
|
|
await expect(
|
|
tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
expect(warnSpy.mock.calls[0][0]).toMatch(/integrity check failed/)
|
|
expect(warnSpy.mock.calls[0][0]).toMatch(/bad bytes/)
|
|
expect(execMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('allowed-roots derivation', () => {
|
|
test('declaredPaths is forwarded to listAndValidate', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['build/', 'node_modules/'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock).toHaveBeenCalledTimes(1)
|
|
const [, , allowedRoots] = listMock.mock.calls[0]
|
|
// Both declared roots should appear after resolution
|
|
expect(allowedRoots.length).toBeGreaterThanOrEqual(2)
|
|
})
|
|
|
|
test('missing declaredPaths falls back to workspace as the sole allowed root', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock).toHaveBeenCalledTimes(1)
|
|
const [, , allowedRoots, extractCwd] = listMock.mock.calls[0]
|
|
// Empty declaredPaths array → fail-safe fallback to extractCwd
|
|
expect(allowedRoots).toEqual([extractCwd])
|
|
})
|
|
})
|
|
|
|
describe('compression method forwarding', () => {
|
|
test('Gzip', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Gzip)
|
|
})
|
|
|
|
test('Zstd', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Zstd, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Zstd)
|
|
})
|
|
|
|
test('ZstdWithoutLong', async () => {
|
|
const listMock = jest
|
|
.spyOn(listAndValidate, 'listAndValidate')
|
|
.mockResolvedValue([])
|
|
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
|
|
|
await tar.extractTar(archive, CompressionMethod.ZstdWithoutLong, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.ZstdWithoutLong)
|
|
})
|
|
})
|
|
|
|
describe("mode 'error' extraction allow-list", () => {
|
|
test('clean archive: extraction restricted to approved members via --null/-T', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
|
violations: [],
|
|
approvedNames: ['cache/a.txt', 'cache/b.txt']
|
|
})
|
|
jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
|
|
|
let command = ''
|
|
let listPath: string | undefined
|
|
let listContents: Buffer | undefined
|
|
jest.spyOn(exec, 'exec').mockImplementation(async cmd => {
|
|
command = cmd
|
|
const m = /-T "([^"]+)"/.exec(command)
|
|
if (m) {
|
|
listPath = m[1]
|
|
listContents = fs.readFileSync(listPath)
|
|
}
|
|
return 0
|
|
})
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'error'
|
|
})
|
|
|
|
// Allow-list flags are present and `--null` precedes `-T`.
|
|
expect(command).toContain('--null')
|
|
expect(command).toContain('--no-recursion')
|
|
expect(command).toMatch(/--null .*-T "[^"]+"/)
|
|
// GNU tar (the tool detected under the io.which mock on non-Windows)
|
|
// additionally receives the wildcard-hardening flags.
|
|
if (process.platform !== 'win32') {
|
|
expect(command).toContain('--no-wildcards')
|
|
expect(command).toContain('--anchored')
|
|
}
|
|
// The list is NUL-separated and contains exactly the approved names.
|
|
expect(listContents?.toString('utf8')).toBe('cache/a.txt\0cache/b.txt\0')
|
|
// The temporary allow-list file is cleaned up after extraction.
|
|
expect(listPath).toBeDefined()
|
|
expect(fs.existsSync(listPath as string)).toBe(false)
|
|
})
|
|
|
|
test('warn mode does not restrict extraction (no -T) even when clean', async () => {
|
|
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
|
violations: [],
|
|
approvedNames: ['cache/a.txt']
|
|
})
|
|
jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
|
|
|
let command = ''
|
|
jest.spyOn(exec, 'exec').mockImplementation(async cmd => {
|
|
command = cmd
|
|
return 0
|
|
})
|
|
|
|
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
|
declaredPaths: ['cache/**'],
|
|
pathValidation: 'warn'
|
|
})
|
|
|
|
expect(command).not.toContain('-T "')
|
|
expect(command).not.toContain('--no-recursion')
|
|
})
|
|
})
|
|
})
|