diff --git a/src/application/releaseExposure/ReleaseExposureService.test.ts b/src/application/releaseExposure/ReleaseExposureService.test.ts index ff86fcc5c0..73ac3c7294 100644 --- a/src/application/releaseExposure/ReleaseExposureService.test.ts +++ b/src/application/releaseExposure/ReleaseExposureService.test.ts @@ -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", diff --git a/src/application/releaseExposure/ReleaseExposureService.ts b/src/application/releaseExposure/ReleaseExposureService.ts index e38519c5c3..fff4cfb3d3 100644 --- a/src/application/releaseExposure/ReleaseExposureService.ts +++ b/src/application/releaseExposure/ReleaseExposureService.ts @@ -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, + digests: ReadonlyMap, options: ExecutionOptions | undefined, ): Promise> { let result: Result; @@ -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, + 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["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]; }; diff --git a/src/artifacts/ReleaseExposureReader.test.ts b/src/artifacts/ReleaseExposureReader.test.ts index 10846e4223..474f928622 100644 --- a/src/artifacts/ReleaseExposureReader.test.ts +++ b/src/artifacts/ReleaseExposureReader.test.ts @@ -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([ diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts index 953e6746be..9cbf6ed17e 100644 --- a/src/artifacts/ReleaseExposureReader.ts +++ b/src/artifacts/ReleaseExposureReader.ts @@ -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; readonly byOccurrence: Map; } @@ -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 = (