fix(audit): match package members by source spelling and close remaining coverage gaps

Reopen package members by their source spelling and keep caller path
traversal in directory reads. Parse quoted and attached Go build flags, bind a
native image fuse read to its own digest, and keep reader resources and the
audit's partial report when cleanup or cancellation combine failures.
This commit is contained in:
Matt Van Horn committed 2026-11-01 07:29:15 -08:00
1 parent d224dfc51c
commit ffc2f2309e
4 files changed
+326 -22

No files matched your search

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
@@ -21,6 +22,7 @@ import {
releaseExposureReportSchema,
type ReleaseExposureScan,
} from "../../domain/releaseExposure.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
@@ -293,9 +295,38 @@ describe("ReleaseExposureService cancellation", () => {
});
});
describe("ReleaseExposureService image identity", () => {
const CHANGED = "cd".repeat(32);
describe("ReleaseExposureService cancellation partials", () => {
it("reports the audit, not an owner's own partial Evidence", async () => {
const reason = new Error("caller stopped");
const { service, goBinaries } = owners(
scan({ native_images: [{ path: "/work/app", format: "elf" }] }),
);
goBinaries.inspect.mockResolvedValue(
err(
new AnalysisCancelledError("inspect_go_binary", {
cause: reason,
partialObservation: evidence({ owner: "go" }),
}),
),
);
const result = await service.inspect({ path: "/work/app" });
if (result.ok) throw new Error("Expected a failed audit.");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
const projected = projectAnalysisError(result.error);
expect(projected.code).toBe("cancelled");
expect(
releaseExposureReportSchema.parse(
(result.error.partialObservation as Evidence).normalized_result,
).limitations,
).toEqual(expect.arrayContaining([expect.stringContaining("cancelled")]));
expect(JSON.stringify(projected)).toContain("javascript.source_maps");
});
});
const CHANGED = "cd".repeat(32);
describe("ReleaseExposureService image identity", () => {
it("does not attach Go facts from a file replaced after inventory", async () => {
const { service, goBinaries } = owners(
scan({
@@ -376,6 +407,70 @@ describe("ReleaseExposureService image identity", () => {
expect(check?.observation.counts.packages).toBe(0);
expect(check?.observation.samples).not.toContain("serde");
});
});
describe("ReleaseExposureService fuse image identity", () => {
it("binds a native-image fuse read to that image, not any inventoried digest", async () => {
const { service, electronFuses } = owners(
scan({
native_images: [
{ path: "/work/app", format: "elf", sha256: CHANGED },
{ path: "/work/helper", format: "elf", sha256: SHA },
],
}),
);
electronFuses.inspect.mockImplementation(async (input) =>
(input as { path: string }).path === "/work/app"
? ok(evidence(fuseReport([slice(0)])))
: err(
new AnalysisUnsupportedTargetError(
"inspect_electron_fuses",
"/work/helper",
"unsupported",
),
),
);
const result = await service.inspect({ path: "/work/app" });
if (!result.ok) throw result.error;
expect(
releaseExposureReportSchema
.parse(result.value.normalized_result)
.checks.find((item) => item.id === "electron.run_as_node")?.status,
).toBe("unknown");
});
it("accepts an app bundle's fuse read when its file is an inventoried image", async () => {
const bundle = scan({
target: {
selected_path: "/work/App.app",
kind: "app",
format: "directory",
sha256: null,
},
fuse_candidates: [{ path: "/work/App.app", role: "app-bundle" }],
});
const status = async (sha256: string) => {
const { service, desktop, electronFuses } = owners({
...bundle,
native_images: [
{ path: "/work/App.app/framework", format: "elf", sha256 },
],
});
desktop.inspect.mockResolvedValue(
err(new AnalysisInputError("inspect_desktop_application")),
);
electronFuses.inspect.mockResolvedValue(
ok(evidence(fuseReport([slice(0)]))),
);
const result = await service.inspect({ path: "/work/App.app" });
if (!result.ok) throw result.error;
return releaseExposureReportSchema
.parse(result.value.normalized_result)
.checks.find((item) => item.id === "electron.run_as_node")?.status;
};
expect(await status(SHA)).toBe("not_observed");
expect(await status(CHANGED)).toBe("unknown");
});
it("does not attach fuse facts when the resolved file left the inventory", async () => {
const { service, electronFuses } = owners(
@@ -733,6 +828,30 @@ describe("ReleaseExposureService cleanup", () => {
await expect(service.close()).rejects.toBe(denied);
});
it("keeps a reader failure's resources beside a typed owner failure", async () => {
const { service, desktop, goBinaries } = owners(scan());
desktop.close.mockRejectedValue(
new ArtifactReaderFailure("unavailable", "close denied", {
cleanup: { reason: "EPERM", resources: ["/work/app/descriptor"] },
}),
);
goBinaries.close.mockRejectedValue(
new ProviderCleanupError(
"rea-go",
["/work/app/go"],
{ reason: "EPERM" },
{ operation: "inspect_go_binary" },
),
);
const failure: unknown = await service.close().catch((cause) => cause);
if (!(failure instanceof ProviderCleanupError))
throw new Error("Expected a typed cleanup failure.");
expect(failure.cleanupResources).toEqual([
"/work/app/descriptor",
"/work/app/go",
]);
});
it("keeps resources and live bindings when several typed owners fail to close", async () => {
const { service, desktop, goBinaries } = owners(scan());
const first = new ProviderCleanupError(
@@ -764,6 +883,41 @@ describe("ReleaseExposureService cleanup", () => {
});
describe("ReleaseExposureService Go paths", () => {
it("keeps Go paths with spaces and attached compiler flags whole", async () => {
const report = await goReport({
path: "github.com/acme/app",
main: null,
dependencies: [],
settings: [
{ key: "CGO_CFLAGS", value: `"-I/opt/my sdk/include" -O2` },
{
key: "CGO_CXXFLAGS",
value: "-isystem/opt/isystem -iquote /opt/quote",
},
{ key: "CGO_LDFLAGS", value: `-L'/opt/my libs' -T/opt/link.ld` },
{ key: "-ldflags", value: `-extldflags "-L/srv/ext lib -static"` },
{ key: "-gcflags", value: "all=-trimpath=/src/a=/b" },
{ key: "-pgo", value: "/srv/default.pgo" },
],
unparsed_lines: [],
unparsed_line_bytes_base64: [],
complete: true,
});
const check = report.checks.find((item) => item.id === "go.build_paths");
expect(check?.status).toBe("exposed");
expect(check?.observation.counts.paths).toBe(9);
expect(check?.observation.samples).toEqual([
"/opt/my sdk/include",
"/opt/isystem",
"/opt/quote",
"/opt/my libs",
"/opt/link.ld",
"/srv/ext lib",
"/src/a",
"/b",
]);
});
it("reports Go paths retained in cgo and linker flags", async () => {
const module = (settings: { key: string; value: string }[]) => ({
path: "github.com/acme/app",
@@ -1,5 +1,6 @@
import { basename, dirname } from "node:path";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { DesktopApplicationService } from "../artifacts/DesktopApplicationService.js";
import type { ElectronFuseService } from "../electron/ElectronFuseService.js";
@@ -156,7 +157,15 @@ export class ReleaseExposureService {
},
);
if (owned.cancelled !== undefined)
return err(owned.cancelled.retainPartialObservation(evidence));
return err(
// An owner's own partial is one image, not this audit's report.
owned.cancelled.partialObservation === undefined
? owned.cancelled.retainPartialObservation(evidence)
: new AnalysisCancelledError(RELEASE_EXPOSURE_OPERATION, {
cause: owned.cancelled.cause,
partialObservation: evidence,
}),
);
return completeProviderEvidence(evidence, options?.signal);
}
@@ -185,15 +194,15 @@ export class ReleaseExposureService {
archivePaths: [],
});
const prepared = limitNativeSample(scan, desktop.archivePaths);
const inventoried = new Set(
const digests = new Map(
scan.native_images.flatMap((image) =>
image.sha256 === undefined ? [] : [image.sha256],
image.sha256 === undefined ? [] : [[image.path, image.sha256] as const],
),
);
const fuseImages: FuseImageFact[] = [];
for (const candidate of prepared.scan.fuse_candidates)
fuseImages.push(
await settle(() => this.#fuseImage(candidate, inventoried, options), {
await settle(() => this.#fuseImage(candidate, digests, options), {
kind: "failed",
role: candidate.role,
path: candidate.path,
@@ -269,7 +278,7 @@ export class ReleaseExposureService {
async #fuseImage(
candidate: ReleaseExposureFuseCandidate,
inventoried: ReadonlySet<string>,
digests: ReadonlyMap<string, string>,
options: ExecutionOptions | undefined,
): Promise<Result<FuseImageFact, AnalysisError>> {
let result: Result<Evidence, AnalysisError>;
@@ -313,9 +322,7 @@ export class ReleaseExposureService {
path: candidate.path,
reason: "Electron fuse report did not match its schema.",
});
// An app bundle resolves to a file the audit did not select by path, so
// its digest is compared with every inventoried native image.
if (inventoried.size > 0 && !inventoried.has(report.data.artifact.sha256))
if (fuseImageChanged(candidate, digests, report.data.artifact.sha256))
return ok({
kind: "failed",
role: candidate.role,
@@ -452,9 +459,15 @@ const ownerCloseFailure = (
const typed = failures.flatMap(({ cause }) =>
cause instanceof AnalysisError ? [cause] : [],
);
const resources = failures.flatMap(({ cause }) => {
if (cause instanceof AnalysisError) return cause.cleanupResources;
return cause instanceof ArtifactReaderFailure
? (cause.cleanup?.resources ?? [])
: [];
});
const aggregate = new ProviderCleanupError(
RELEASE_EXPOSURE_PROVIDER.id,
[...new Set(typed.flatMap((cause) => cause.cleanupResources))],
[...new Set(resources)],
{
failures: failures.map(({ owner, cause }) => ({
owner,
@@ -480,6 +493,20 @@ const ownerCloseFailure = (
return aggregate;
};
/**
* An app bundle resolves to a file through paths the inventory may spell
* differently, so its digest must match some inventoried native image.
*/
const fuseImageChanged = (
candidate: ReleaseExposureFuseCandidate,
digests: ReadonlyMap<string, string>,
sha256: string,
): boolean => {
const expected = digests.get(candidate.path);
if (expected !== undefined) return expected !== sha256;
return digests.size > 0 && ![...digests.values()].includes(sha256);
};
const imageChanged = (
image: ReleaseExposureNativeImage,
sha256: string,
@@ -661,11 +688,27 @@ const filesystemPath = (value: string | null | undefined): string | null => {
return null;
};
/** Go build settings whose values are compiler, linker, or cgo flags. */
const GO_FLAG_SETTING = /^(?:CGO_[A-Z]+FLAGS|-asmflags|-gcflags|-ldflags)$/u;
/** A path value after a flag prefix, `=`, `,`, or a quote, as in `-I/opt/include`. */
/** Go build settings whose values are flags or a build input path. */
const GO_FLAG_SETTING =
/^(?:CGO_[A-Z]+FLAGS|-asmflags|-gccgoflags|-gcflags|-ldflags|-pgo)$/u;
/** One whitespace-separated field. Quotes keep their spaces inside it. */
const GO_FLAG_FIELD = /(?:[^\s"']|"[^"]*"|'[^']*')+/gu;
/** A path after a flag prefix, whitespace, `=`, or `,`, as in `-isystem/opt/x`. */
const GO_FLAG_PATH =
/(?:^|[\s=,"'])(?:-[BFIL])?((?:\.{1,2}\/|\/|[A-Za-z]:[\\/])[^\s,"']+)/gu;
/(?:^|[\s=,])(?:-(?:idirafter|imacros|include|iprefix|iquote|isysroot|isystem|[BFILT]))?((?:\.{1,2}\/|\/|[A-Za-z]:[\\/])[^=,]+)/gu;
/** Paths in one flag value, with a quoted path kept whole up to the next flag. */
const goFlagPaths = (value: string): string[] =>
[...value.matchAll(GO_FLAG_FIELD)].flatMap(([field]) =>
field
.replace(/["']/gu, "")
.split(/\s+(?=-)/u)
.flatMap((flag) =>
[...flag.matchAll(GO_FLAG_PATH)].flatMap(([, path]) =>
path === undefined ? [] : [path.trimEnd()],
),
),
);
const goPaths = (
module: NonNullable<GoBinary["build_info"]>["module"],
@@ -684,7 +727,7 @@ const goPaths = (
}
for (const setting of module.settings) {
if (!GO_FLAG_SETTING.test(setting.key)) continue;
for (const match of setting.value.matchAll(GO_FLAG_PATH)) add(match[1]);
for (const path of goFlagPaths(setting.value)) add(path);
}
return [...paths];
};
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import { deflateRawSync } from "node:zlib";
import type { Dirent } from "node:fs";
import { mkdir, symlink, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -1260,6 +1261,98 @@ describe("ReleaseExposureReader text recognition", () => {
});
});
describe("ReleaseExposureReader source spellings", () => {
const decomposed = "caf\u0065\u0301";
const key = "-----BEGIN PRIVATE KEY-----\n";
it("reopens a decomposed ASAR member through its source spelling", async () => {
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
await writeFile(
join(root, "app.asar"),
asarArchive([
{ name: `${decomposed}.js.map`, bytes: embeddedSourceMap() },
{ name: `${decomposed}.txt`, bytes: Buffer.from(key) },
]),
);
const scanned = await inspect(root);
expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 });
expect(scanned.text_members.scanned).toBeGreaterThan(0);
expect(scanned.text_members.skipped).toBe(0);
expect(scanned.extractable).toMatchObject({ unreadable_members: 0 });
});
it("reopens a decomposed compressed .NET member", async () => {
const stored = deflateRawSync(Buffer.from(key));
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
await writeFile(
join(root, "app.dll"),
dotnetBundleFixture([
{
path: `${decomposed}.txt`,
bytes: Buffer.from(key),
compressed: true,
storedBytes: stored,
},
]).bytes,
);
const scanned = await inspect(join(root, "app.dll"));
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
expect(scanned.extractable).toMatchObject({
readable_members: 1,
unreadable_members: 0,
});
});
it("finds a compressed member whose source name differs from its path", async () => {
const root = await createTestTempDirectory("rea-release-exposure-src-");
const file = join(root, "app.exe");
await writeFile(
file,
cArchiveExecutable({
carrier: "pe",
layout: "2.1",
pythonVersion: 310,
members: [
{
name: "docs\\notes.txt",
typeCode: "x",
compress: true,
payload: Buffer.from(key),
},
],
}),
);
const scanned = await inspect(file);
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
expect(scanned.extractable).toMatchObject({
readable_members: 1,
unreadable_members: 0,
});
});
it("reads a decomposed directory member by its source spelling", async () => {
const root = await createTestTempDirectory("rea-release-exposure-nfd-");
await mkdir(join(root, "nested"));
const name = join(root, "nested", `${decomposed}.txt`);
await writeFile(name, key);
const scanned = await inspect(root);
expect(scanned.textLocations).toEqual([name]);
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
});
it("keeps symlink traversal in a selected directory path", async () => {
const root = await createTestTempDirectory("rea-release-exposure-link-");
await mkdir(join(root, "real", "child"), { recursive: true });
await writeFile(join(root, "real", "notes.txt"), key);
await writeFile(join(root, "notes.txt"), "decoy\n");
await symlink(join(root, "real", "child"), join(root, "link"));
const selected = `${root}/link/..`;
const scanned = await inspect(selected);
expect(scanned.textLocations).toEqual([`${selected}/notes.txt`]);
expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 });
});
});
describe("ReleaseExposureReader package traversal coverage", () => {
it("keeps a package reader's unknown coverage for an unexpanded member", async () => {
const root = await bundled([
+20 -6
View File
@@ -1,6 +1,6 @@
import type { Dirent } from "node:fs";
import { lstat, opendir } from "node:fs/promises";
import { basename, dirname, isAbsolute, join } from "node:path";
import { basename, dirname, isAbsolute } from "node:path";
import type { Readable } from "node:stream";
import type {
@@ -53,6 +53,7 @@ import {
readMachoHeaderPrefixFacts,
} from "../domain/apple/machoContainer.js";
import { inspectPeCodeViewFromBuffer } from "../domain/binaryTarget.js";
import { logicalPathNfc } from "../domain/artifactIdentity.js";
import { observeMzWindowsHeader } from "../domain/dosMz.js";
import { evidenceSubjectFormatSchema } from "../domain/evidenceSubject.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
@@ -138,7 +139,11 @@ interface OpenContainer {
readonly path: string;
readonly reader: ArtifactReader;
entries: readonly ArtifactEntry[] | null;
/** Same-path entries stay distinct: each occurrence takes the next one. */
/**
* Keyed by source spelling, which inventory keeps when it normalizes a
* logical path. Same-path entries stay distinct: each occurrence takes the
* next one.
*/
readonly byPath: Map<string, ArtifactEntry[]>;
readonly byOccurrence: Map<string, ArtifactEntry | null>;
}
@@ -1455,7 +1460,9 @@ const readContainerMember = async (
let entry = container.byOccurrence.get(occurrence.occurrence_id);
if (entry === undefined) {
entry =
container.byPath.get(entryPath(occurrence, parent))?.shift() ?? null;
container.byPath
.get(occurrence.source_path ?? entryPath(occurrence, parent))
?.shift() ?? null;
container.byOccurrence.set(occurrence.occurrence_id, entry);
}
if (entry === null || entry.kind !== "file") return { kind: "unread" };
@@ -1531,8 +1538,9 @@ const containerEntries = async (
continue;
}
listed.push(entry);
const sameName = container.byPath.get(entry.path);
if (sameName === undefined) container.byPath.set(entry.path, [entry]);
const sourceName = entry.sourcePath ?? entry.path;
const sameName = container.byPath.get(sourceName);
if (sameName === undefined) container.byPath.set(sourceName, [entry]);
else sameName.push(entry);
}
const coverage = traversal.snapshot();
@@ -1821,7 +1829,13 @@ const diskPath = (
if (occurrence.logical_path.split("/").includes("..")) return null;
if (occurrence.logical_path.length === 0 || occurrence.logical_path === ".")
return input.path;
return join(input.path, occurrence.logical_path);
const source = occurrence.source_path;
return appendCallerPath(
input.path,
source !== undefined && logicalPathNfc(source) === occurrence.logical_path
? source
: occurrence.logical_path,
);
};
const packageAncestor = (