Merge pull request #3108 from code-yeongyu/fix/prepublish-tar-failopen

fix(security): make tar archive preflight fail-closed on unparsed entries
This commit is contained in:
YeonGyu-Kim
2026-04-04 14:56:44 +09:00
committed by GitHub
2 changed files with 156 additions and 6 deletions
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as logger from "../logger"
import { parseTarListingOutput } from "./tar-zip-entry-listing"
function createTarFileLine(fileName: string): string {
return `-rw-r--r-- 1 user group 123 Jan 01 12:34 ${fileName}`
}
function getWarnedUnparsedLines(logSpy: ReturnType<typeof spyOn>): string[] {
return logSpy.mock.calls.flatMap(([message, data]) => {
if (
message !== "warning: unparsed tar listing line" ||
typeof data !== "object" ||
data === null ||
!("line" in data) ||
typeof data.line !== "string"
) {
return []
}
return [data.line]
})
}
function captureThrownError(run: () => void): Error {
try {
run()
} catch (error) {
if (error instanceof Error) {
return error
}
}
throw new Error("Expected parser to throw")
}
describe("parseTarListingOutput", () => {
afterEach(() => {
mock.restore()
})
describe("#given tar output with any unparsed lines", () => {
it("#when parsing the output #then throws immediately (fail-closed)", () => {
// given
const logSpy = spyOn(logger, "log").mockImplementation(() => {})
const listedOutput = [
createTarFileLine("file-1.txt"),
createTarFileLine("file-2.txt"),
"unparsed listing line",
].join("\n")
// when
const thrownError = captureThrownError(() => parseTarListingOutput(listedOutput))
// then
expect(thrownError.message).toMatch(/could not be parsed/i)
expect(getWarnedUnparsedLines(logSpy)).toContain("unparsed listing line")
})
})
describe("#given tar output with multiple unparsed lines", () => {
it("#when parsing the output #then throws with count details", () => {
// given
const logSpy = spyOn(logger, "log").mockImplementation(() => {})
const listedOutput = [
createTarFileLine("file-1.txt"),
createTarFileLine("file-2.txt"),
createTarFileLine("file-3.txt"),
createTarFileLine("file-4.txt"),
createTarFileLine("file-5.txt"),
createTarFileLine("file-6.txt"),
createTarFileLine("file-7.txt"),
createTarFileLine("file-8.txt"),
"unparsed listing line 1",
"unparsed listing line 2",
].join("\n")
// when
const thrownError = captureThrownError(() => parseTarListingOutput(listedOutput))
// then
expect(thrownError.message).toMatch(/could not be parsed/i)
expect(getWarnedUnparsedLines(logSpy)).toEqual(
expect.arrayContaining([
"unparsed listing line 1",
"unparsed listing line 2",
])
)
})
})
describe("#given tar output where every non-empty line is unparsed", () => {
it("#when parsing the output #then rejects the listing", () => {
// given
const logSpy = spyOn(logger, "log").mockImplementation(() => {})
// when
const thrownError = captureThrownError(() =>
parseTarListingOutput(["unknown format 1", "unknown format 2"].join("\n"))
)
// then
expect(thrownError.message).toMatch(/could not be parsed/i)
expect(getWarnedUnparsedLines(logSpy)).toEqual(
expect.arrayContaining(["unknown format 1", "unknown format 2"])
)
})
})
})
@@ -1,6 +1,9 @@
import { spawn } from "bun"
import type { ArchiveEntry } from "../archive-entry-validator"
import { log } from "../logger"
function parseTarListedZipEntry(line: string): ArchiveEntry | null {
const match = line.match(
@@ -26,6 +29,48 @@ function parseTarListedZipEntry(line: string): ArchiveEntry | null {
}
}
function validateParsedTarListing(
totalLineCount: number,
unparsedLines: string[]
): void {
if (unparsedLines.length === 0) {
return
}
throw new Error(
`zip entry listing failed: ${unparsedLines.length}/${totalLineCount} tar listing lines could not be parsed (fail-closed)`
)
}
export function parseTarListingOutput(stdout: string): ArchiveEntry[] {
const listingLines = stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
if (listingLines.length === 0) {
return []
}
const parsedEntries: ArchiveEntry[] = []
const unparsedLines: string[] = []
for (const listingLine of listingLines) {
const parsedEntry = parseTarListedZipEntry(listingLine)
if (parsedEntry === null) {
unparsedLines.push(listingLine)
log("warning: unparsed tar listing line", { line: listingLine })
continue
}
parsedEntries.push(parsedEntry)
}
validateParsedTarListing(listingLines.length, unparsedLines)
return parsedEntries
}
export async function listZipEntriesWithTar(
archivePath: string
): Promise<ArchiveEntry[]> {
@@ -44,10 +89,5 @@ export async function listZipEntriesWithTar(
throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`)
}
return stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => parseTarListedZipEntry(line))
.filter((entry): entry is ArchiveEntry => entry !== null)
return parseTarListingOutput(stdout)
}