Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cache/RELEASES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# @actions/cache Releases

## 6.2.0

- Add opt-in cache-archive path validation during restore via `DownloadOptions.pathValidation` (`'off' | 'warn' | 'error'`). When enabled, downloaded archives are streamed through an in-process `node-tar` validator before extraction; entries (and link targets) that escape the declared cache `paths` are reported as warnings or rejected with a `CacheIntegrityError`. In `'error'` mode on a clean archive, system `tar` extraction is additionally restricted to the validator-approved members, and parser-differential defenses (length-correct PAX re-parsing, unsafe-character / glob-metacharacter / unknown-PAX-key rejection) guard against listing-vs-extraction bypasses.

## 6.1.0

- Handle cache write error due to read-only token: detect the `cache write denied:` prefix on cache reservation failures and surface it as a `core.warning` (without failing the run).
Expand Down
385 changes: 385 additions & 0 deletions packages/cache/__tests__/listAndValidate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
import {mkdirSync, mkdtempSync, writeFileSync, rmSync} from 'fs'
import * as os from 'os'
import * as path from 'path'
import * as zlib from 'zlib'
import {execSync} from 'child_process'
import {Header} from 'tar'
import {CompressionMethod} from '../src/internal/constants'
import {listAndValidate as listAndValidateImpl} from '../src/internal/listAndValidate'

/**
* Thin wrapper returning only the violations array, so the many existing
* assertions below can keep treating the result as a flat list. New tests that
* need the `approvedNames` allow-list call `listAndValidateImpl` directly.
*/
const listAndValidate = async (
...args: Parameters<typeof listAndValidateImpl>
): Promise<Awaited<ReturnType<typeof listAndValidateImpl>>['violations']> =>
(await listAndValidateImpl(...args)).violations

/**
* Real-archive integration tests for listAndValidate. These build small tar
* archives in-memory using `tar.Header`, write them to disk, and run them
* through the same parser the production code uses. No mocks.
*/

interface TestEntry {
path: string
type: 'File' | 'Directory' | 'SymbolicLink' | 'Link' | 'CharacterDevice'
linkpath?: string
body?: Buffer
}

function buildTarArchive(entries: TestEntry[]): Buffer {
const blocks: Buffer[] = []
for (const entry of entries) {
const body = entry.body ?? Buffer.alloc(0)
const header = new Header({
path: entry.path,
mode: 0o644,
uid: 0,
gid: 0,
size: body.length,
mtime: new Date(0),
type: entry.type,
linkpath: entry.linkpath,
uname: 'root',
gname: 'root'
})
const headerBuf = Buffer.alloc(512)
header.encode(headerBuf, 0)
blocks.push(headerBuf)
if (body.length > 0) {
blocks.push(body)
const pad = (512 - (body.length % 512)) % 512
if (pad > 0) blocks.push(Buffer.alloc(pad))
}
}
// Two zero blocks mark end of archive.
blocks.push(Buffer.alloc(1024))
return Buffer.concat(blocks)
}

const TEST_ROOT = mkdtempSync(path.join(os.tmpdir(), 'cache-listAndValidate-'))

/**
* Detect whether the `zstd` binary is on PATH at module load. We use a
* conditional `describe.skip` (rather than per-test early-returns) so that
* Jest reports skipped tests as `skipped` in its summary — silently
* `return`-ing from a test reports it as `passed`, which masks coverage gaps
* on machines where zstd is missing.
*/
const ZSTD_AVAILABLE = ((): boolean => {
try {
execSync(process.platform === 'win32' ? 'where zstd' : 'which zstd', {
stdio: 'ignore'
})
return true
} catch {
return false
}
})()
const describeZstd = ZSTD_AVAILABLE ? describe : describe.skip

function workspace(): string {
return path.join(TEST_ROOT, 'workspace')
}

function writeArchive(name: string, data: Buffer): string {
mkdirSync(TEST_ROOT, {recursive: true})
const fullPath = path.join(TEST_ROOT, name)
writeFileSync(fullPath, data)
return fullPath
}

beforeAll(() => {
mkdirSync(workspace(), {recursive: true})
})

afterAll(() => {
try {
rmSync(TEST_ROOT, {recursive: true, force: true})
} catch {
// best-effort cleanup
}
})

describe('listAndValidate (real archives)', () => {
describe('uncompressed tar', () => {
test('clean archive: zero violations', async () => {
const archive = buildTarArchive([
{path: 'cache/file1.txt', type: 'File', body: Buffer.from('hello')},
{path: 'cache/sub/file2.txt', type: 'File', body: Buffer.from('world')},
{path: 'cache/sub/', type: 'Directory'}
])
const archivePath = writeArchive('clean.tar', archive)
// Inject a fake gzip header by re-compressing? No — we want to test
// the uncompressed path. listAndValidate doesn't have a "raw" code
// path; it always assumes compression based on `compressionMethod`.
// To test uncompressed bytes we run it through gzip and pass Gzip.
const gzipped = zlib.gzipSync(archive)
writeFileSync(archivePath, gzipped)

const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toEqual([])
})
})

describe('gzip-compressed tar', () => {
test('clean archive: zero violations', async () => {
const archive = buildTarArchive([
{path: 'cache/file.txt', type: 'File', body: Buffer.from('hi')}
])
const archivePath = writeArchive('clean.tar.gz', zlib.gzipSync(archive))
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toEqual([])
})

test('classic ../../../etc/passwd traversal: one violation', async () => {
const archive = buildTarArchive([
{path: 'cache/legit.txt', type: 'File', body: Buffer.from('ok')},
{
path: '../../../etc/passwd',
type: 'File',
body: Buffer.from('pwned')
}
])
const archivePath = writeArchive(
'traversal.tar.gz',
zlib.gzipSync(archive)
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
expect(violations[0].path).toBe('../../../etc/passwd')
expect(violations[0].entryType).toBe('File')
})

test('absolute path entry: one violation', async () => {
const absPath =
process.platform === 'win32'
? 'C:/Windows/System32/evil.dll'
: '/etc/cron.d/evil'
const archive = buildTarArchive([
{path: absPath, type: 'File', body: Buffer.from('x')}
])
const archivePath = writeArchive('abs.tar.gz', zlib.gzipSync(archive))
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
expect(violations[0].path).toBe(absPath)
})

test('symlink with absolute target: one violation', async () => {
const archive = buildTarArchive([
{
path: 'cache/link',
type: 'SymbolicLink',
linkpath: '/etc/passwd'
}
])
const archivePath = writeArchive(
'symlink-abs.tar.gz',
zlib.gzipSync(archive)
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
expect(violations[0].entryType).toBe('SymbolicLink')
expect(violations[0].linkpath).toBe('/etc/passwd')
})

test('symlink target traversing out of allowed roots: one violation', async () => {
const archive = buildTarArchive([
{
path: 'cache/link',
type: 'SymbolicLink',
linkpath: '../../../etc/passwd'
}
])
const archivePath = writeArchive(
'symlink-traverse.tar.gz',
zlib.gzipSync(archive)
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
})

test('hardlink to ../etc/passwd: one violation', async () => {
const archive = buildTarArchive([
{
path: 'cache/link',
type: 'Link',
linkpath: '../../../etc/passwd'
}
])
const archivePath = writeArchive(
'hardlink-traverse.tar.gz',
zlib.gzipSync(archive)
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
expect(violations[0].entryType).toBe('Link')
})

test('mixed clean and malicious entries: only the bad ones are reported', async () => {
const archive = buildTarArchive([
{path: 'cache/a.txt', type: 'File', body: Buffer.from('1')},
{path: '../escape.txt', type: 'File', body: Buffer.from('2')},
{path: 'cache/sub/b.txt', type: 'File', body: Buffer.from('3')},
{
path: 'cache/link',
type: 'SymbolicLink',
linkpath: '/tmp/x'
},
{path: 'cache/sub/c.txt', type: 'File', body: Buffer.from('4')}
])
const archivePath = writeArchive('mixed.tar.gz', zlib.gzipSync(archive))
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
const paths = violations.map(v => v.path).sort()
expect(paths).toEqual(['../escape.txt', 'cache/link'])
})

test('character device entry is rejected', async () => {
const archive = buildTarArchive([
{path: 'cache/dev', type: 'CharacterDevice'}
])
const archivePath = writeArchive('chardev.tar.gz', zlib.gzipSync(archive))
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
expect(violations[0].entryType).toBe('CharacterDevice')
})

test('archive with a single small entry: zero violations', async () => {
const archive = buildTarArchive([
{path: 'cache/tiny.txt', type: 'File', body: Buffer.from('1')}
])
const archivePath = writeArchive('single.tar.gz', zlib.gzipSync(archive))
const violations = await listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toEqual([])
})

test('corrupted / non-tar bytes: throws Error', async () => {
const archivePath = writeArchive(
'corrupt.tar.gz',
zlib.gzipSync(Buffer.from('this is not a tar archive at all'))
)
await expect(
listAndValidate(
archivePath,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
).rejects.toThrow()
})
})

describeZstd('zstd-compressed tar', () => {
test('clean archive (Zstd with --long): zero violations', async () => {
const archive = buildTarArchive([
{path: 'cache/x.bin', type: 'File', body: Buffer.from('hello')}
])
// Compress via the zstd binary with --long=30 to mirror the real
// cache-creation pipeline.
const archivePath = path.join(TEST_ROOT, 'clean.tar.zst')
mkdirSync(TEST_ROOT, {recursive: true})
writeFileSync(`${archivePath}.raw`, archive)
execSync(
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
{stdio: 'ignore'}
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Zstd,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toEqual([])
})

test('traversal in zstd archive: one violation', async () => {
const archive = buildTarArchive([
{path: '../escape.txt', type: 'File', body: Buffer.from('x')}
])
const archivePath = path.join(TEST_ROOT, 'evil.tar.zst')
writeFileSync(`${archivePath}.raw`, archive)
execSync(
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
{stdio: 'ignore'}
)
const violations = await listAndValidate(
archivePath,
CompressionMethod.Zstd,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toHaveLength(1)
})

test('ZstdWithoutLong compression method works', async () => {
const archive = buildTarArchive([
{path: 'cache/y.bin', type: 'File', body: Buffer.from('z')}
])
const archivePath = path.join(TEST_ROOT, 'clean.short.tar.zst')
writeFileSync(`${archivePath}.raw`, archive)
execSync(`zstd --force -o "${archivePath}" "${archivePath}.raw"`, {
stdio: 'ignore'
})
const violations = await listAndValidate(
archivePath,
CompressionMethod.ZstdWithoutLong,
[path.join(workspace(), 'cache')],
workspace()
)
expect(violations).toEqual([])
})
})
})
Loading
Loading