diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index e05b59c893..baea9f529b 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -168,6 +168,7 @@ explicit path or endpoint and do not need it. Facts retain all-recorded-events-before-selection scope. Other qlog layouts and PCAP correlation are unsupported. - User-owned Electron runtime already open: `list_electron_targets`. Before an electron-main Inspector attempt, check packaged fuses with `inspect_electron_fuses`; disabled `EnableNodeCliInspectArguments` means the app ignores `--inspect`. +- Shipped application exposure for a local `.app`, install directory, ASAR, or executable: `audit_release_exposure` / `rea audit-release-exposure`. Each check has a stable id, a status, and fixed remediation. Unknown and unassessed checks stay in the report. This does not establish that an application is secure. Credential-marker hits report the rule id and location only. See [release exposure](https://github.com/morluto/rea/blob/main/docs/release-exposure.md). - Packet-field discovery: `inspect_packet_fields` searches installed protocols and field names/types before choosing fields for `inspect_packet_capture`. Query/page the relevant records; registrations do not establish observed traffic. diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index e4d84e2b68..151f38ad1c 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -62,6 +62,16 @@ runtime presence. For compact CLI output, use `inspect-desktop-application PATH --detail summary --evidence_output BUNDLE`. See [desktop installations](https://github.com/morluto/rea/blob/main/docs/desktop-applications.md). +## Release exposure + +Use `audit_release_exposure` (`rea audit-release-exposure PATH`) when the question is what static analysis of a shipped `.app`, install directory, ASAR, or executable recovers. Each check has a stable id, a status, the observation behind it, and fixed remediation. Unknown and unassessed checks stay in the report. The report does not establish that the application is secure. + +`text.credential_markers` reports a rule id and location only. Matched bytes, previews, lengths, and hashes of matched bytes are omitted. + +`native.hardening`, `macho.hardened_runtime`, and `android.manifest` are not assessed here. Their next probes are `inspect_binary_hardening`, `inspect_signature`, and `inspect_android_package`. + +See [release exposure](https://github.com/morluto/rea/blob/main/docs/release-exposure.md). + ## Go build metadata Use `inspect_go_binary` for an explicit local ELF, PE or Mach-O (universal diff --git a/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md b/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md index e05b59c893..baea9f529b 100644 --- a/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md +++ b/.cursor-plugin/skills/reverse-engineer-anything/SKILL.md @@ -168,6 +168,7 @@ explicit path or endpoint and do not need it. Facts retain all-recorded-events-before-selection scope. Other qlog layouts and PCAP correlation are unsupported. - User-owned Electron runtime already open: `list_electron_targets`. Before an electron-main Inspector attempt, check packaged fuses with `inspect_electron_fuses`; disabled `EnableNodeCliInspectArguments` means the app ignores `--inspect`. +- Shipped application exposure for a local `.app`, install directory, ASAR, or executable: `audit_release_exposure` / `rea audit-release-exposure`. Each check has a stable id, a status, and fixed remediation. Unknown and unassessed checks stay in the report. This does not establish that an application is secure. Credential-marker hits report the rule id and location only. See [release exposure](https://github.com/morluto/rea/blob/main/docs/release-exposure.md). - Packet-field discovery: `inspect_packet_fields` searches installed protocols and field names/types before choosing fields for `inspect_packet_capture`. Query/page the relevant records; registrations do not establish observed traffic. diff --git a/.cursor-plugin/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.cursor-plugin/skills/reverse-engineer-anything/references/native-and-artifacts.md index e4d84e2b68..151f38ad1c 100644 --- a/.cursor-plugin/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.cursor-plugin/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -62,6 +62,16 @@ runtime presence. For compact CLI output, use `inspect-desktop-application PATH --detail summary --evidence_output BUNDLE`. See [desktop installations](https://github.com/morluto/rea/blob/main/docs/desktop-applications.md). +## Release exposure + +Use `audit_release_exposure` (`rea audit-release-exposure PATH`) when the question is what static analysis of a shipped `.app`, install directory, ASAR, or executable recovers. Each check has a stable id, a status, the observation behind it, and fixed remediation. Unknown and unassessed checks stay in the report. The report does not establish that the application is secure. + +`text.credential_markers` reports a rule id and location only. Matched bytes, previews, lengths, and hashes of matched bytes are omitted. + +`native.hardening`, `macho.hardened_runtime`, and `android.manifest` are not assessed here. Their next probes are `inspect_binary_hardening`, `inspect_signature`, and `inspect_android_package`. + +See [release exposure](https://github.com/morluto/rea/blob/main/docs/release-exposure.md). + ## Go build metadata Use `inspect_go_binary` for an explicit local ELF, PE or Mach-O (universal diff --git a/README.md b/README.md index 8019f431a3..78b7064b80 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Additional tools and host support depend on the target: | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/guides/evm/evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | | Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/guides/native/recorded-crashes.md) | -| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; Linux: procps-compatible `ps`; [application analysis](docs/guides/javascript/javascript-application-workflows.md) | +| JavaScript / Electron | Modules, imports, source maps, routes, IPC, native add-ons, `rea audit-release-exposure` | Node.js and npm; Linux: procps-compatible `ps`; [application analysis](docs/guides/javascript/javascript-application-workflows.md) | | Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](docs/guides/web/browser-observation.md) | | Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/guides/web/web-network-captures.md) | | .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/guides/managed/managed-code-analysis.md) | diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index 33a5d13301..1d2b59ecbb 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -660,6 +660,11 @@ export const documentationSections = [ source: "reconstruction-coverage.md", route: "/reconstruction-coverage", }, + { + text: "Release exposure", + source: "release-exposure.md", + route: "/release-exposure", + }, { text: "Reverse-engineering tool integrations", source: "reverse-engineering-tools.md", diff --git a/docs/README.md b/docs/README.md index a4a499b7ce..7a8dce70ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -197,6 +197,7 @@ the exact commit messages and references from the release they describe. - [Python packaged applications](python-packaged-applications.md) - [CPython pyc inspection](python-pyc.md) - [Reconstruction coverage](reconstruction-coverage.md) +- [Release exposure](release-exposure.md) - [Reverse-engineering tool integrations](reverse-engineering-tools.md) - [Rust binary dependency metadata](rust-binaries.md) - [U-Boot legacy uImage containers](uimage.md) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index fc9e607bc2..e493ed5ee7 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -1428,6 +1428,7 @@ the CLI result should use JSON; either argument order is accepted. | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. | | `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. | +| `3` | `audit-release-exposure` completed with `--fail_on` and an exposed check meets that severity. Unknown and unassessed checks do not change this status. | | `128 + N` | Signal-derived status. An unhandled signal may have ended the process, with the shell or runtime preserving the conventional status; a command may instead have handled the signal, waited for its operation and cleanup to settle, and assigned the same status (`130` for SIGINT, `143` for SIGTERM). In the handled case, it may also have printed a structured `cancelled` result. | `rea mcp --http` is long-running and prints no structured result. It exits @@ -1561,6 +1562,18 @@ Bun standalone executables use the existing `inspect-artifact` and sourcemaps and bytecode without running the executable. See [Bun standalone executables](../bun-standalone.md) for layout coverage and limits. +### Release exposure + +```sh +rea audit-release-exposure /absolute/path/to/MyApp.app --json +rea audit-release-exposure ./dist/MyApp.app --fail_on high --json +``` + +Reports what static analysis of a shipped `.app`, install directory, ASAR, or +executable recovers. `--fail_on high|medium|low` exits `3` when a completed +audit has an exposed check at that severity. Unknown and unassessed checks stay +in the report and do not change the exit status. See +[Release exposure](../release-exposure.md). Deno compile executables use the same commands to inventory and materialize the embedded virtual filesystem, remote modules, and recorded options. Inventory retains embedded environment-file values and certificate bytes in local Evidence with their diff --git a/docs/index.md b/docs/index.md index a48bf94153..7b41d0a55e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ The [documentation directory](README.md) lists every guide by domain. | Inspect native functions, references, or types | [Native binaries](guides/native/native-investigation.md), [Go metadata](guides/native/go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [initializer functions](guides/native/native-initializers.md), [ELF unwind ranges](guides/native/elf-unwind-ranges.md), [Linux kernel modules](kernel-modules.md), [Linux kernel build config](guides/native/kernel-build-config.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](guides/native/binary-diagnostics.md), [recorded crashes](guides/native/recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](providers/ghidra-provider.md), [Windows](providers/windows-ghidra-p0.md), [DOS](providers/ghidra-dos.md), [raw bytes](providers/raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](providers/ghidra-mips.md), [RISC-V](providers/ghidra-riscv.md), [PowerPC](ghidra-ppc.md), [PSP](providers/ghidra-psp.md), [NativeAOT](providers/ghidra-nativeaot.md), [IDA](providers/ida-provider.md), [Kuna](kuna-provider.md), [JEB](providers/jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](guides/javascript/javascript-artifact-reconstruction.md), [tracing and comparisons](guides/javascript/javascript-application-workflows.md), [application graph](guides/javascript/javascript-application-graph.md), [source recovery](guides/javascript/javascript-recovery.md) | -| Compare static JavaScript with runtime observations | [Runtime attribution](guides/javascript/javascript-runtime-observation.md), [reconciliation](guides/javascript/javascript-runtime-reconciliation.md), [Electron observation](guides/javascript/electron-observation.md), [Electron fuses](electron-fuses.md) | +| Compare static JavaScript with runtime observations | [Runtime attribution](guides/javascript/javascript-runtime-observation.md), [reconciliation](guides/javascript/javascript-runtime-reconciliation.md), [Electron observation](guides/javascript/electron-observation.md), [Electron fuses](electron-fuses.md), [Release exposure](release-exposure.md) | | Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [wxapkg](wxapkg.md), [U-Boot uImage](uimage.md), [.NET](guides/managed/managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](guides/android/android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](guides/android/apktool-resource-analysis.md), [ADB acquisition](guides/android/adb-device-analysis.md), [Apple](guides/apple/apple-application-analysis.md), [Harmony](guides/harmony/harmony-analysis.md), [firmware](guides/firmware/firmware-analysis.md), [Device Tree](devicetree.md), [FIT images](fit-images.md), [UF2/DfuSe](uf2-dfuse.md), [Flutter](guides/android/flutter-analysis.md), [ESP images](esp-image.md), [ESP flash images](esp-flash-image.md), [WASM](guides/wasm/wasm-artifacts.md), [EVM](guides/evm/evm-bytecode.md) | | Observe or compare a browser scenario | [CDP observation](guides/web/browser-observation.md), [scenarios](guides/web/browser-scenario-contract.md), [runtime evidence](guides/web/web-runtime.md), [historical captures](guides/web/web-network-captures.md) | | Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | diff --git a/docs/release-exposure.md b/docs/release-exposure.md new file mode 100644 index 0000000000..edbfda5443 --- /dev/null +++ b/docs/release-exposure.md @@ -0,0 +1,67 @@ +# Release exposure + +`audit_release_exposure` / `audit-release-exposure` reads one shipped artifact and reports what static analysis of that artifact recovers. The input is a macOS `.app` bundle, an install directory, an ASAR, or a single executable. Each check has a stable id, a status, the observation behind that status, and fixed remediation text. + +The report does not establish that an application is secure. A check that could not be completed is `unknown` or `not_assessed`, and those counts stay beside the findings. There is no pass verdict and no letter grade. + +```bash +rea audit-release-exposure /Applications/Example.app --json +rea audit-release-exposure ./dist/MyApp.app --fail_on high --json +``` + +Without `--fail_on`, the exit status reflects only whether the audit finished. With `--fail_on high`, `--fail_on medium`, or `--fail_on low`, a completed audit exits `3` when an `exposed` check meets that severity. `high` matches high. `medium` matches high and medium. `low` matches high, medium, and low. `unknown` and `not_assessed` do not change the exit status. A tool failure is still exit `1`. + +The CLI resolves a relative path against the caller's working directory. MCP requires an absolute filesystem path and returns the same report. MCP has no exit status. + +```json +{ + "name": "audit_release_exposure", + "arguments": { "path": "/Applications/Example.app" } +} +``` + +## What is read + +The audit composes existing readers. It does not reread Electron fuse bytes itself, and it does not rewrite the artifact. + +| Check | Exposed when | Severity | +| ------------------------------- | ------------------------------------------------------------------------------ | -------- | +| `javascript.source_map_sources` | A shipped source map has `sourcesContent` | high | +| `javascript.source_maps` | A shipped source map has no embedded sources | medium | +| `electron.run_as_node` | The fuse implication is honored | high | +| `electron.asar_integrity` | Embedded ASAR integrity is not enforced | medium | +| `electron.only_load_from_asar` | Loading is not restricted to `app.asar` | medium | +| `electron.node_cli_inspect` | Node CLI inspect arguments are honored | medium | +| `packaging.extractable_payload` | A Bun, Deno, Node SEA, PyInstaller, .NET bundle, or ASAR has a readable member | low | +| `native.debug_companions` | A `.pdb`, `.dSYM`, `.debug`, or linker `.map` ships beside the release | medium | +| `native.pdb_path` | A PE CodeView record names a PDB path | low | +| `go.build_paths` | Go build info or its build flags contain file paths | low | +| `rust.dependency_metadata` | cargo-auditable metadata is present | low | +| `text.credential_markers` | A text member contains a provider-prefixed marker | high | +| `native.hardening` | Not assessed in this audit | | +| `macho.hardened_runtime` | Not assessed in this audit | | +| `android.manifest` | Not assessed in this audit | | + +`go.build_paths` reads a flag's arguments by the rules of the tool that takes them. A linker `-X` value is an application string, so a relative one is not a path. A rooted one cannot be told from a file path, so it is reported: `-X main.route=/api/v1` is sampled like `-X main.root=/srv/build`, because dropping both would miss the second. + +`text.credential_markers` matches provider-prefixed forms such as a PEM private-key header and documented vendor token prefixes. The report contains the rule id and location only. Matched bytes, previews, lengths, and hashes of matched bytes are omitted. + +`native.hardening`, `macho.hardened_runtime`, and `android.manifest` stay `not_assessed`. Their next probes are `inspect_binary_hardening`, `inspect_signature`, and `inspect_android_package`. + +Source map text is counted and then dropped. A selected file is read through the stable artifact reader. Text and source-map members stay inside the allowances listed under `coverage.allowances`, and each kind has its own allowance, so binary members cannot use up the read that a later source map needs. Native images on disk are registered from inventory classification and are not limited to those allowances. Other files and package members are identified from a bounded header. The bytes a package member's text or map read returns decide before its name does, so a PE called `payload.js.map` is read as a PE. A header with no NUL byte, or one that starts with a UTF-16 byte order mark, is scanned as text, so names such as `.env.production`, `server.key`, and `credentials` do not depend on an extension list. UTF-16 without a byte order mark has NUL bytes and is not recognized this way. A member already known to be text, such as a script, is also scanned when it is UTF-8 holding a stray NUL. One whose header is at least an eighth NUL bytes is taken to be wide text. It is not decoded and is counted as skipped, as is UTF-32. A binary plist is decoded, so its strings, dictionary keys and data are scanned in whichever encoding each object uses. One that cannot be decoded still has its bytes scanned and is counted as skipped. A file whose header could not be read leaves `text.credential_markers` undecided. A packaged ELF, Mach-O, or PE image is read in full and written to a temporary copy, removed afterward, so it can be probed for an embedded package. All packaged images share one `max_file_bytes` allowance. An image that does not fit, or whose bytes cannot be read, is not probed and leaves member coverage `unknown`. A packaged PE is also read for its debug records. An SVG file is read for its scripts as an HTML file is. A script that an HTML file loads, or a Node SEA main script, is examined as a script whatever it is called, so a name such as `boot.map` does not make it a map, and an HTML file loaded this way is still examined as HTML. Its bytes still decide over that role: a `.map` or loaded script whose text parses as a source map, a JSON object with `version` and `mappings`, `sources`, or `sections`, is read as a source map, so `', + ); + await writeFile(join(root, "boot.map"), "console.log('boot');\n"); + const scanned = await inspect(join(root, "index.html")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + expect(debugCompanionsStatus(scanned)).not.toBe("exposed"); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + }); + + it("does not report a native image beside a selected file as a linker map", async () => { + const root = await createTestTempDirectory("rea-release-sibling-native-"); + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + await writeFile(join(root, "release.map"), elf); + await writeFile(join(root, "app"), "plain file"); + const scanned = await inspect(join(root, "app")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + expect(debugCompanionsStatus(scanned)).not.toBe("exposed"); + }); + + it("keeps a large PE beside a selected file with a late header unknown", async () => { + const root = await createTestTempDirectory("rea-release-sibling-pe-"); + // The PE signature sits past the 4 KiB probe of a map past its allowance. + const image = Buffer.alloc(2 * 1024 * 1024); + image.write("MZ", 0, "latin1"); + image.writeUInt32LE(8192, 60); + image.write("PE\0\0", 8192, "latin1"); + await writeFile(join(root, "payload.map"), image); + await writeFile(join(root, "app"), "plain file"); + const scanned = await inspect(join(root, "app")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 }); + expect(debugCompanionsStatus(scanned)).not.toBe("exposed"); + }); + + it("does not report a Node script beside a selected file as a linker map", async () => { + const root = await createTestTempDirectory("rea-release-sibling-shebang-"); + await writeFile( + join(root, "runner.map"), + "#!/usr/bin/env node\nrequire('./main');\n", + ); + await writeFile(join(root, "app"), "plain file"); + const scanned = await inspect(join(root, "app")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + expect(debugCompanionsStatus(scanned)).not.toBe("exposed"); + }); +}); + +describe("ReleaseExposureReader acquisition and sibling review regressions", () => { + it.each(["external section", "decoder limit", "malformed JSON"])( + "keeps a sibling map with an unsupported %s unresolved", + async (scenario) => { + const root = await createTestTempDirectory("rea-release-sibling-map-"); + let map: unknown = { version: 3, sources: [], mappings: "" }; + if (scenario === "external section") + map = { + version: 3, + sections: [{ offset: { line: 0, column: 0 }, url: "external.map" }], + }; + else if (scenario === "decoder limit") + for (let depth = 0; depth < 66; depth += 1) + map = { + version: 3, + sections: [{ offset: { line: 0, column: 0 }, map }], + }; + await writeFile( + join(root, "bundle.map"), + scenario === "malformed JSON" ? "{broken" : JSON.stringify(map), + ); + await writeFile(join(root, "app"), "plain file"); + const scanned = await inspect(join(root, "app")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 }); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + without_sources: 0, + unparsed: 0, + skipped: 0, + samples: [], + }); + }, + ); + it("bounds package inventory acquisition before hashing members past the entry cap", async () => { + const entryCap = 3; + const root = await createTestTempDirectory( + "rea-release-inventory-entries-", + ); + const archive = join(root, "app.asar"); + await writeFile( + archive, + asarArchive( + Array.from({ length: entryCap + 1 }, (_, index) => ({ + name: `member${String(index)}.bin`, + bytes: Buffer.from("x"), + })), + ), + ); + let opened = 0; + const actual = await vi.importActual< + typeof import("./inventory/ArtifactInventory.js") + >("./inventory/ArtifactInventory.js"); + // Exercise real acquisition with a small cap instead of hashing 65,536 + // members; verify the production allowance is forwarded separately. + const acquire = vi.fn( + (path, options) => + actual.inventoryArtifactInScope(path, { + ...options, + acquisitionLimits: { + maximumEntries: entryCap, + maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES, + }, + }), + ); + inventoryRead.impl = acquire; + let inventoryReader: ArtifactReader | undefined; + createdReaders.wrap = (source) => { + if (inventoryReader !== undefined) return source; + inventoryReader = source; + const open = source.open.bind(source); + source.open = (...args) => { + opened += 1; + return open(...args); + }; + return source; + }; + try { + const scanned = await inspect(archive); + expect(acquire).toHaveBeenCalledExactlyOnceWith( + archive, + expect.objectContaining({ + acquisitionLimits: { + maximumEntries: MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES, + maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES, + }, + }), + ); + expect(opened).toBe(entryCap); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.limitations.join("\n")).toContain( + "acquisition allowance exhausted", + ); + } finally { + inventoryRead.impl = undefined; + createdReaders.wrap = undefined; + } + }); + + it("bounds directory inventory bytes before opening an oversized member", async () => { + const root = await createTestTempDirectory("rea-release-inventory-bytes-"); + await writeFile(join(root, "large.bin"), ""); + await truncate( + join(root, "large.bin"), + MAX_RELEASE_EXPOSURE_INVENTORY_BYTES + 1, + ); + const reader = new ReleaseExposureReader(); + let opened = 0; + createdReaders.wrap = (source) => { + const open = source.open.bind(source); + source.open = (...args) => { + opened += 1; + return open(...args); + }; + return source; + }; + try { + const result = await reader.inspect( + { path: root }, + undefined, + ignoreText, + ); + if (!result.ok) throw result.error; + expect(opened).toBe(0); + expect(result.value.coverage).toBe("unknown"); + expect(result.value.limitations.join("\n")).toContain( + "acquisition allowance exhausted", + ); + } finally { + createdReaders.wrap = undefined; + await reader.close(); + } + }); +}); + +describe("ReleaseExposureReader member allowances", () => { + it.each(["unrelated.js.map", "unrelated.map"])( + "keeps sibling source map %s out of the selected artifact's counts", + async (name) => { + const root = await createTestTempDirectory( + "rea-release-exposure-sibling-", + ); + await writeFile(join(root, "app"), "plain file"); + await writeFile(join(root, name), embeddedSourceMap()); + const scanned = await inspect(join(root, "app")); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + without_sources: 0, + unparsed: 0, + skipped: 0, + samples: [], + }); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + expect(scanned.coverage).toBe("complete"); + }, + ); + + it("keeps a later source map readable after many large binary members", async () => { + const root = await createTestTempDirectory("rea-release-exposure-budget-"); + const binary = Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES); + const oversizedScript = Buffer.alloc( + MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, + 0x20, + ); + await writeFile( + join(root, "app.asar"), + asarArchive([ + ...Array.from({ length: 160 }, (_, index) => ({ + name: `a${String(index)}.bin`, + bytes: binary, + })), + { name: "b.js", bytes: oversizedScript }, + { name: "z.js.map", bytes: embeddedSourceMap() }, + ]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 1 }); + expect(scanned.extractable.readable_members).toBe(162); + }, 60_000); + + it("reports an oversized linker map as a debug companion wherever it ships", async () => { + const root = await createTestTempDirectory("rea-release-exposure-link-"); + const linkerMap = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + linkerMap.write("Archive member included to satisfy reference\n"); + await mkdir(join(root, "bin")); + await writeFile(join(root, "bin", "app.map"), linkerMap); + await writeFile(join(root, "bin", "app"), "not a package"); + const directory = await inspect(join(root, "bin")); + expect(directory.debug_companions.paths).toEqual([ + expect.stringMatching(/bin\/app\.map$/u), + ]); + expect(directory.source_maps.skipped).toBe(0); + + const selected = await inspect(join(root, "bin", "app")); + expect(selected.debug_companions.paths).toEqual([ + join(root, "bin", "app.map"), + ]); + expect(selected.source_maps.skipped).toBe(0); + + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "app.map", bytes: linkerMap }]), + ); + const packaged = await inspect(join(root, "app.asar")); + expect(packaged.debug_companions.paths).toEqual(["app.map"]); + expect(packaged.source_maps.skipped).toBe(0); + }); + + it("leaves an oversized JSON .map undecided as both map kinds", async () => { + const root = await createTestTempDirectory("rea-release-exposure-json-"); + const map = Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, 0x20); + map.write('{"version":3,"sources":[]}'); + await writeFile(join(root, "bundle.map"), map); + const scanned = await inspect(root); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 }); + expect(scanned.source_maps.skipped).toBe(1); + expect(scanned.text_members.skipped).toBe(1); + }); + + it("keeps whitespace-only prefixes of oversized maps unresolved", async () => { + const root = await createTestTempDirectory("rea-release-exposure-spaces-"); + await writeFile( + join(root, "bundle.map"), + Buffer.concat([ + Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, 0x20), + embeddedSourceMap(), + ]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps.skipped).toBe(1); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 1 }); + }); + + it("does not spend the source-map allowance reading a JavaScript map past the count", async () => { + const root = await createTestTempDirectory("rea-release-exposure-count-"); + const map = embeddedSourceMap(); + for (let index = 0; index < 33; index += 1) + await writeFile(join(root, `m${String(index)}.js.map`), map); + const scanned = await inspect(root); + expect(scanned.source_maps.embedded).toBe(32); + expect(scanned.source_maps.skipped).toBe(1); + expect(scanned.text_members.skipped).toBe(1); + }); +}); + +describe("ReleaseExposureReader native member names", () => { + it("inspects a packaged PE whose name reads as text", async () => { + const root = await createTestTempDirectory("rea-release-exposure-name-"); + await writeFile( + join(root, "app.asar"), + asarArchive([ + { + name: "payload.json", + bytes: pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\named.pdb", + }), + }, + ]), + ); + const scanned = await inspect(root); + expect(scanned.pdb.paths).toContain("C:\\build\\named.pdb"); + expect(scanned.omitted.map(({ subject }) => subject)).toContain( + "packaged native images", + ); + }); +}); + +describe("ReleaseExposureReader nested packages in a host", () => { + it("leaves coverage unknown for an ASAR a PyInstaller host carries", async () => { + const root = await createTestTempDirectory("rea-release-exposure-nest-"); + await writeFile( + join(root, "app.exe"), + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: [ + { + name: "resources", + typeCode: "x", + compress: true, + payload: asarArchive([ + { name: "app.js.map", bytes: Buffer.from("{}") }, + ]), + }, + ], + }), + ); + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.extractable.formats).toContain("asar"); + expect(scanned.limitations.join("\n")).toContain("was not expanded"); + }); + + it.each([ + [ + "PyInstaller", + cArchiveExecutable({ + carrier: "elf", + layout: "2.1", + pythonVersion: 310, + members: [ + { + name: "app.js.map", + typeCode: "x", + compress: true, + payload: embeddedSourceMap(), + }, + ], + }), + ], + [ + "Bun", + elfBunSection( + bunPrefixedPayload( + bunModuleGraphPayload({ + modules: [ + { + name: "/$bunfs/root/main.js", + contents: Buffer.from("run();\n"), + sourcemap: embeddedSourceMap(), + }, + ], + }), + ), + ), + ], + ["Node SEA", nodeSeaElf()], + [ + ".NET", + dotnetBundleFixture([{ path: "app.js.map", bytes: embeddedSourceMap() }]) + .bytes, + ], + ])("keeps a %s executable in an ASAR unknown", async (_, executable) => { + const root = await createTestTempDirectory("rea-release-nested-exe-"); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "tool", bytes: executable }]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.source_maps.embedded).toBe(0); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.limitations.join("\n")).toContain("was not expanded"); + expect(scanned.extractable.readable_members).toBe(1); + }); + + it.each([ + ["an inflated", false, "complete"], + ["a corrupt", true, "unknown"], + ])( + "settles coverage for %s compressed text member of a host", + async (_, corrupt, coverage) => { + const root = await createTestTempDirectory("rea-release-exposure-zlib-"); + const payload = Buffer.from('{"name":"app"}\n'); + const executable = cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: [ + { name: "payload.json", typeCode: "x", compress: true, payload }, + ], + }); + // Past the two-byte zlib header, 0xff opens a block of reserved type 3. + const stream = executable.indexOf(deflateSync(payload)); + if (corrupt) executable[stream + 2] = 0xff; + await writeFile(join(root, "app.exe"), executable); + const scanned = await inspect(root); + expect(scanned.coverage).toBe(coverage); + expect(scanned.text_members.skipped).toBe(corrupt ? 1 : 0); + }, + ); + + it("keeps coverage complete past a packaged image with no package", async () => { + const root = await createTestTempDirectory("rea-release-plain-exe-"); + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "addon.node", bytes: elf }]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.coverage).toBe("complete"); + expect(sourceMapSourcesStatus(scanned)).toBe("not_observed"); + }); + + it("keeps a packaged image past the byte allowance unknown", async () => { + const root = await createTestTempDirectory("rea-release-large-exe-"); + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + await writeFile( + join(root, "app.asar"), + asarArchive([ + { name: "first.node", bytes: elf }, + { name: "second.node", bytes: elf }, + ]), + ); + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect( + { path: root, max_file_bytes: 96 }, + undefined, + () => undefined, + ); + if (!result.ok) throw result.error; + expect(result.value.coverage).toBe("unknown"); + expect(result.value.omitted).toContainEqual( + expect.objectContaining({ subject: "embedded package probes" }), + ); + } finally { + await reader.close(); + } + }); +}); + +describe("ReleaseExposureReader unread packaged maps", () => { + it.each(["payload.map", "payload.js.map"])( + "keeps coverage unknown for a corrupt compressed %s in an ELF host", + async (name) => { + const root = await createTestTempDirectory("rea-release-exposure-zlib-"); + const payload = Buffer.from('{"version":3}\n'); + const executable = cArchiveExecutable({ + carrier: "elf", + layout: "2.1", + pythonVersion: 310, + members: [{ name, typeCode: "x", compress: true, payload }], + }); + // Past the two-byte zlib header, 0xff opens a block of reserved type 3. + executable[executable.indexOf(deflateSync(payload)) + 2] = 0xff; + await writeFile(join(root, "app"), executable); + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.limitations).toContain( + `Could not read ${join(root, "app")}/${name} to rule out a native image or package.`, + ); + }, + ); +}); + +describe("ReleaseExposureReader undecided PE members", () => { + it.each(["payload.map", "payload.bin", "payload.json"])( + "keeps a large PE member named %s with a late header undecided", + async (name) => { + const root = await createTestTempDirectory("rea-release-exposure-pe-"); + // The PE signature sits past the retained prefix of an oversized member. + const image = Buffer.alloc(2 * 1024 * 1024); + image.write("MZ", 0, "latin1"); + image.writeUInt32LE(8192, 60); + image.write("PE\0\0", 8192, "latin1"); + await writeFile( + join(root, "app.exe"), + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: [{ name, typeCode: "x", compress: false, payload: image }], + }), + ); + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect( + scanned.debug_companions.paths.some((path) => path.endsWith(name)), + ).toBe(false); + }, + ); +}); + +describe("ReleaseExposureReader undecided PE text", () => { + it("still scans the text of a small undecided PE map member", async () => { + const root = await createTestTempDirectory("rea-release-exposure-pe-"); + // `MZ`, then ASCII whose bytes 60-63 point past the end of the member. + const payload = Buffer.from( + `MZ${"a".repeat(80)}\n-----BEGIN PRIVATE KEY-----\n${PLANTED_KEY}\n-----END PRIVATE KEY-----\n`, + "latin1", + ); + const target = join(root, "app.exe"); + await writeFile( + target, + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: [ + { name: "payload.map", typeCode: "x", compress: false, payload }, + ], + }), + ); + const reader = new ReleaseExposureReader(); + try { + const texts: string[] = []; + const result = await reader.inspect( + { path: target }, + undefined, + (text) => { + texts.push(text); + }, + ); + if (!result.ok) throw result.error; + expect(result.value.coverage).toBe("unknown"); + expect(texts.some((text) => text.includes("BEGIN PRIVATE KEY"))).toBe( + true, + ); + } finally { + await reader.close(); + } + }); +}); + +describe("ReleaseExposureReader source-map count", () => { + it.each([ + [ + "dotnet-bundle", + (bytes: Buffer, paths: readonly string[]): Buffer => + dotnetBundleFixture( + paths.map((path) => ({ path, bytes, compressed: true })), + ).bytes, + ], + [ + "pyinstaller-carchive", + (bytes: Buffer, paths: readonly string[]): Buffer => + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: paths.map((name) => ({ + name, + typeCode: "x", + compress: true, + payload: bytes, + })), + }), + ], + ])( + "charges an oversized %s map to the map count before opening it", + async (_format, archive) => { + const root = await createTestTempDirectory("rea-release-exposure-over-"); + const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 8; + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + const paths = Array.from( + { length: count }, + (_, index) => `m${String(index)}.js.map`, + ); + const target = join(root, "app.dll"); + await writeFile(target, archive(oversized, paths)); + let opened = 0; + createdReaders.wrap = (reader, path) => { + if (path !== target) return reader; + const open = reader.open.bind(reader); + reader.open = (entry, signal) => { + if (entry.path.endsWith(".js.map")) opened += 1; + return open(entry, signal); + }; + return reader; + }; + try { + const scanned = await inspect(root); + expect(opened).toBe(MAX_RELEASE_EXPOSURE_SOURCE_MAPS); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + skipped: count, + }); + expect(scanned.text_members.skipped).toBe(count); + } finally { + createdReaders.wrap = undefined; + } + }, + ); + + it("keeps a linker map's outcome past the count once oversized maps use it", async () => { + const root = await createTestTempDirectory("rea-release-exposure-spent-"); + const oversized = Buffer.alloc( + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, + 0x20, + ); + oversized.write("Archive member included to satisfy reference\n"); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture( + Array.from( + { length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1 }, + (_, index) => ({ + path: `m${String(index)}.map`, + bytes: oversized, + compressed: true, + }), + ), + ).bytes, + ); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toHaveLength( + MAX_RELEASE_EXPOSURE_SOURCE_MAPS + 1, + ); + expect(scanned.source_maps.skipped).toBe(0); + }); +}); + +describe("ReleaseExposureReader sibling map allowance", () => { + const countSiblingReads = async ( + run: () => Promise, + ): Promise<{ scanned: ReleaseExposureScan; budgets: number[] }> => { + const budgets: number[] = []; + const real = stableRead.impl; + stableRead.impl = async (...args) => { + if (real === undefined) throw new Error("stable read is not installed"); + if (args[0].endsWith(".map")) budgets.push(args[1]); + return real(...args); + }; + try { + return { scanned: await run(), budgets }; + } finally { + stableRead.impl = real; + } + }; + + it("stops full sibling map reads at the map count", async () => { + const root = await createTestTempDirectory("rea-release-sibling-budget-"); + const extra = 3; + const count = MAX_RELEASE_EXPOSURE_SOURCE_MAPS + extra; + await Promise.all( + Array.from({ length: count }, (_, index) => + writeFile( + join(root, `s${String(index)}.map`), + JSON.stringify({ version: 3, sources: [], mappings: "" }), + ), + ), + ); + await writeFile(join(root, "app"), "plain file"); + const { scanned, budgets } = await countSiblingReads(() => + inspect(join(root, "app")), + ); + expect(budgets).toHaveLength(count); + expect( + budgets.filter( + (bytes) => bytes === MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, + ), + ).toHaveLength(MAX_RELEASE_EXPOSURE_SOURCE_MAPS); + expect( + budgets.filter( + (bytes) => bytes === MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, + ), + ).toHaveLength(extra); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: extra }); + expect(scanned.source_maps).toMatchObject({ skipped: 0, samples: [] }); + }); + + it("settles sibling maps from the probe once the selected file spends the count", async () => { + const root = await createTestTempDirectory("rea-release-sibling-spent-"); + const map = Buffer.from( + JSON.stringify({ version: 3, sources: [], mappings: "" }), + ); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture( + Array.from( + { length: MAX_RELEASE_EXPOSURE_SOURCE_MAPS }, + (_, index) => ({ + path: `m${String(index)}.js.map`, + bytes: map, + compressed: true, + }), + ), + ).bytes, + ); + const pastProbe = MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES + 1; + const linker = "Archive member included to satisfy reference\n"; + await writeFile(join(root, "short-linker.map"), linker); + await writeFile( + join(root, "long-linker.map"), + linker.padEnd(pastProbe, " "), + ); + await writeFile(join(root, "short-json.map"), map); + await writeFile(join(root, "long-json.map"), "{".padEnd(pastProbe, " ")); + const { scanned, budgets } = await countSiblingReads(() => + inspect(join(root, "app.dll")), + ); + expect(budgets).toEqual( + Array.from({ length: 4 }, () => MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES), + ); + expect([...scanned.debug_companions.paths].sort()).toEqual([ + join(root, "long-linker.map"), + join(root, "short-linker.map"), + ]); + expect(scanned.debug_companions.skipped).toBe(2); + expect(scanned.source_maps.skipped).toBe(0); + }); +}); + +const bundled = async ( + files: readonly { path: string; bytes: Buffer }[], +): Promise => { + const root = await createTestTempDirectory("rea-release-exposure-bundle-"); + await writeFile(join(root, "app.dll"), dotnetBundleFixture(files).bytes); + return root; +}; + +/** One dictionary whose value objects are the given encoded objects. */ +const binaryPlist = (key: string, value: Buffer): Buffer => { + const objects = [ + Buffer.from([0xd1, 1, 2]), + Buffer.concat([Buffer.from([0x50 | key.length]), Buffer.from(key)]), + value, + ]; + const table = + 8 + objects.reduce((length, object) => length + object.length, 0); + const bytes = Buffer.alloc(table + objects.length + 32); + let cursor = 8; + const offsets = objects.map((object) => { + const offset = cursor; + object.copy(bytes, cursor); + cursor += object.length; + return offset; + }); + return binaryPlistObjectTable(bytes, offsets, table); +}; +/** Length-prefixed object body; every test value here exceeds the short form. */ +const plistObject = (type: number, length: number, body: Buffer): Buffer => + Buffer.concat([Buffer.from([(type << 4) | 0x0f, 0x10, length]), body]); + +describe("ReleaseExposureReader text encodings", () => { + const marker = "-----BEGIN PRIVATE KEY-----\n"; + const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64")}`; + + const utf16 = (text: string, order: "le" | "be"): Buffer => { + const little = Buffer.from(`\ufeff${text}`, "utf16le"); + return order === "le" ? little : Buffer.from(little).swap16(); + }; + + const visited = async (path: string) => { + const reader = new ReleaseExposureReader(); + try { + const texts: string[] = []; + const result = await reader.inspect({ path }, undefined, (text) => { + texts.push(text); + }); + if (!result.ok) throw result.error; + return { texts, members: result.value.text_members }; + } finally { + await reader.close(); + } + }; + + it.each(["le", "be"] as const)( + "decodes UTF-16 %s members instead of scanning NUL-interleaved bytes", + async (order) => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const plist = `${marker}`; + await writeFile(join(root, "Info.plist"), utf16(plist, order)); + await writeFile(join(root, "credentials"), utf16(marker, order)); + const { texts, members } = await visited(root); + expect(texts.sort()).toEqual([marker, plist].sort()); + expect(members).toEqual({ scanned: 2, skipped: 0 }); + }, + ); + + it("counts wide text it cannot decode as skipped, whatever the member declares", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const wide = Buffer.from(marker, "utf16le"); + await writeFile(join(root, "bomless.plist"), wide); + await writeFile(join(root, "bomless.js"), wide); + await writeFile( + join(root, "wide.plist"), + Buffer.concat([ + Buffer.from([0xff, 0xfe, 0, 0]), + Buffer.from( + [...marker].flatMap((char) => [char.charCodeAt(0), 0, 0, 0]), + ), + ]), + ); + const { texts, members } = await visited(root); + expect(texts).toEqual([]); + expect(members).toEqual({ scanned: 0, skipped: 3 }); + }); + + it("scans UTF-8 that holds a NUL, with its inline maps", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const script = `const sep = "\0";\nconst key = \`${marker}\`;\n${inlineMap}\n`; + await writeFile(join(root, "app.js"), script); + const scanned = await inspect(root); + expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 }); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect((await visited(root)).texts).toEqual([script]); + }); + + it("does not read NUL-bearing bytes that are not UTF-8 as complete text", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile( + join(root, "app.js"), + Buffer.concat([ + Buffer.from(`const sep = "\0";\n`), + Buffer.from([0xff]), + Buffer.alloc(64, 0x20), + ]), + ); + const { texts, members } = await visited(root); + expect(texts).toEqual([]); + expect(members).toEqual({ scanned: 0, skipped: 1 }); + }); + + it.each([ + ["ASCII", plistObject(5, marker.length, Buffer.from(marker))], + [ + "UTF-16BE", + plistObject( + 6, + `clé: ${marker}`.length, + utf16(`clé: ${marker}`, "be").subarray(2), + ), + ], + ["data", plistObject(4, marker.length, Buffer.from(marker))], + ])("scans a binary plist's %s value", async (_encoding, value) => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile(join(root, "Info.plist"), binaryPlist("key", value)); + const { texts, members } = await visited(root); + expect(texts.join("")).toContain(marker); + expect(members).toEqual({ scanned: 1, skipped: 0 }); + }); + + it("scans a binary plist a package member's name does not announce as text", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const wide = plistObject(6, marker.length, utf16(marker, "be").subarray(2)); + await writeFile( + join(root, "app.exe"), + cArchiveExecutable({ + carrier: "pe", + layout: "2.1", + pythonVersion: 310, + members: [ + { + name: "Localizable.strings", + typeCode: "x", + compress: true, + payload: binaryPlist("key", wide), + }, + ], + }), + ); + const { texts, members } = await visited(root); + expect(texts.join("")).toContain(marker); + expect(members).toEqual({ scanned: 1, skipped: 0 }); + }); + + it("keeps an undecodable binary plist's bytes in the scan, unassessed", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const bytes = Buffer.concat([ + Buffer.from("bplist00"), + Buffer.from([0]), + Buffer.from(marker), + ]); + await writeFile(join(root, "Info.plist"), bytes); + const { texts, members } = await visited(root); + expect(texts.join("")).toContain(marker); + expect(members).toEqual({ scanned: 0, skipped: 1 }); + }); + + it("reads an inline map from a UTF-16 script", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile( + join(root, "app.js"), + utf16(`console.log(1);\n${inlineMap}\n`, "le"), + ); + const scanned = await inspect(root); + expect(scanned.text_members).toEqual({ scanned: 1, skipped: 0 }); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + }); +}); + +describe("ReleaseExposureReader text recognition", () => { + const marker = "-----BEGIN PRIVATE KEY-----\n"; + + it("scans a text file whose name is outside the extension list", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile(join(root, ".env.production"), marker); + await writeFile(join(root, "server.key"), marker); + await writeFile(join(root, "credentials"), marker); + await writeFile( + join(root, "image.dat"), + Buffer.concat([Buffer.from("PNG"), Buffer.alloc(16)]), + ); + await writeFile(join(root, "empty"), ""); + const scanned = await inspect(root); + expect(scanned.textLocations.sort()).toEqual([ + join(root, ".env.production"), + join(root, "credentials"), + join(root, "server.key"), + ]); + expect(scanned.text_members).toEqual({ scanned: 3, skipped: 0 }); + }); + + it("scans text past the probe prefix and counts text past the member allowance", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile( + join(root, "long"), + Buffer.alloc(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES * 2, 0x20), + ); + await writeFile( + join(root, "oversized"), + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20), + ); + const scanned = await inspect(root); + expect(scanned.textLocations).toEqual([join(root, "long")]); + expect(scanned.text_members).toEqual({ scanned: 1, skipped: 1 }); + }); + + it("recognizes a package member by content", async () => { + const root = await bundled([ + { path: "credentials", bytes: Buffer.from(marker) }, + { path: "payload", bytes: Buffer.alloc(32) }, + ]); + const scanned = await inspect(root); + const host = join(root, "app.dll"); + expect(scanned.textLocations).toContain(`${host}/credentials`); + expect(scanned.textLocations).not.toContain(`${host}/payload`); + }); + + it("leaves text undecided when a file could not be probed", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const opaque = join(root, "credentials"); + await writeFile(opaque, marker); + const reader = new ReleaseExposureReader(); + let denied = 0; + const real = stableRead.impl; + stableRead.impl = async (...args) => { + const [path] = args; + if (path !== opaque && real !== undefined) return real(...args); + denied += 1; + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }; + try { + const result = await reader.inspect( + { path: root }, + undefined, + ignoreText, + ); + if (!result.ok) throw result.error; + expect(denied).toBe(1); + expect(result.value.text_members.skipped).toBe(1); + } finally { + stableRead.impl = real; + await reader.close(); + } + }); +}); + +describe("ReleaseExposureReader nested ASAR admission", () => { + it("keeps a nested ASAR past the header admission unknown", async () => { + const root = await createTestTempDirectory( + "rea-release-nested-asar-limit-", + ); + const map = embeddedSourceMap(); + const json = JSON.stringify({ + files: { "app.js.map": { size: map.length, offset: "0" } }, + }); + const inner = Buffer.concat([ + asarHeaderBytes(`${" ".repeat(1100)}${json}`), + map, + ]); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture([{ path: "payload.json", bytes: inner }]).bytes, + ); + const scanned = await inspect(join(root, "app.dll"), { + maximumBytes: 1024, + source: "runtime", + }); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.limitations).toContain( + "Could not decide whether payload.json holds an ASAR within the header admission.", + ); + }); + + it("refuses a selected ASAR past the header admission with its recovery", async () => { + const root = await createTestTempDirectory( + "rea-release-selected-asar-limit-", + ); + const json = JSON.stringify({ files: {} }); + await writeFile( + join(root, "app.asar"), + asarHeaderBytes(`${" ".repeat(1100)}${json}`), + ); + const reader = new ReleaseExposureReader({ + maximumBytes: 1024, + source: "runtime", + }); + try { + const result = await reader.inspect( + { path: join(root, "app.asar") }, + undefined, + () => undefined, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error.limit).toMatchObject({ + setting: "asar.max_header_bytes", + effective_value: 1024, + }); + } finally { + await reader.close(); + } + }); +}); + +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(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.text_members.scanned).toBeGreaterThan(0); + expect(scanned.text_members.skipped).toBe(0); + expect(scanned.extractable).toMatchObject({ unreadable_members: 0 }); + }); + + it("follows a percent-encoded decomposed map name in an ASAR", async () => { + const root = await createTestTempDirectory("rea-release-exposure-nfd-map-"); + await writeFile( + join(root, "app.asar"), + asarArchive([ + { + name: "app.js", + bytes: Buffer.from( + "const value = 1;\n//# sourceMappingURL=cafe%CC%81.json\n", + ), + }, + { name: `${decomposed}.json`, bytes: embeddedSourceMap() }, + ]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(scanned.source_maps.embedded_paths).toHaveLength(1); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("keeps a nested ASAR that was not expanded unknown", async () => { + const root = await createTestTempDirectory("rea-release-nested-asar-"); + const inner = asarArchive([ + { name: "app.js.map", bytes: embeddedSourceMap() }, + ]); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "inner.asar", bytes: inner }]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.source_maps.embedded).toBe(0); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.extractable.readable_members).toBe(1); + expect(scanned.extractable.samples).toEqual([ + expect.stringContaining("inner.asar"), + ]); + }); + + it.each(["payload.json", "payload.map", "payload.js.map"])( + "keeps a nested ASAR an ASAR lists as %s unknown", + async (name) => { + const root = await createTestTempDirectory("rea-release-nested-asar-"); + const inner = asarArchive([ + { name: "app.js.map", bytes: embeddedSourceMap() }, + ]); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name, bytes: inner }]), + ); + const scanned = await inspect(join(root, "app.asar")); + expect(scanned.source_maps).toMatchObject({ embedded: 0, unparsed: 0 }); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.limitations).toContain( + `Nested package ${name} was not expanded.`, + ); + }, + ); + + 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([ + { path: "notes.txt", bytes: Buffer.from("plain\n") }, + ]); + const opaque = "An opaque archive was not expanded."; + const actual = await vi.importActual< + typeof import("./inventory/ArtifactInventory.js") + >("./inventory/ArtifactInventory.js"); + let inventoried = false; + inventoryRead.impl = async (...args) => { + const inventory = await actual.inventoryArtifactInScope(...args); + inventoried = true; + return inventory; + }; + let reported = 0; + createdReaders.wrap = (source) => { + const entries = source.entries.bind(source); + source.entries = (signal, options) => { + // Inventory finished, so only this audit's own listing sees the gap. + if (inventoried) { + reported += 1; + options?.onCoverage?.({ status: "unknown", limitations: [opaque] }); + } + return entries(signal, options); + }; + return source; + }; + try { + const scanned = await inspect(root); + expect(reported).toBeGreaterThan(0); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.limitations).toContain(opaque); + } finally { + inventoryRead.impl = undefined; + createdReaders.wrap = undefined; + } + }); +}); + +describe("ReleaseExposureReader unexpanded packages", () => { + it("does not count an oversized binary entry as a text member", async () => { + const root = await bundled([ + { + path: "Large.bin", + bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1), + }, + ]); + const scanned = await inspect(root); + expect(scanned.text_members.skipped).toBe(0); + expect(scanned.omitted.map((item) => item.subject)).not.toContain( + "text members", + ); + }); + + it("reports members skipped by an allowance as readable, not unreadable", async () => { + const root = await bundled([ + { + path: "Large.bin", + bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1), + }, + { + path: "large.js", + bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20), + }, + ]); + const scanned = await inspect(root); + expect(scanned.extractable).toMatchObject({ + readable_members: 2, + unreadable_members: 0, + }); + expect(scanned.text_members.skipped).toBe(1); + }); + + it("names the host file on every member location", async () => { + const root = await bundled([ + { path: "notes.txt", bytes: Buffer.from("plain\n") }, + { path: "app.js.map", bytes: embeddedSourceMap() }, + ]); + const scanned = await inspect(root); + const host = join(root, "app.dll"); + expect(scanned.textLocations).toContain(`${host}/notes.txt`); + expect(scanned.source_maps.embedded_paths).toEqual([`${host}/app.js.map`]); + expect(scanned.extractable.samples).toContain(`${host}/notes.txt`); + expect(scanned.native_images.map((image) => image.path)).toEqual([host]); + }); + + it("reports a debug companion stored in the package under its host", async () => { + const root = await bundled([{ path: "App.pdb", bytes: Buffer.from("x") }]); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([ + `${join(root, "app.dll")}/App.pdb`, + ]); + }); + + it("leaves a PE too large to read undecided instead of absent", async () => { + const image = Buffer.concat([ + pe(0x8664, 64, 0x0002, false, { pdbPath: "C:\\build\\app.pdb" }), + Buffer.alloc(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES), + ]); + const root = await bundled([{ path: "Large.dll", bytes: image }]); + const scanned = await inspect(root); + expect(scanned.pdb.failed).toBe(1); + expect(scanned.pdb.paths).toEqual([]); + expect(scanned.limitations.join("\n")).toContain("Large.dll"); + expect(scanned.extractable.unreadable_members).toBe(0); + }); + + it("reads CodeView paths from a small packaged PE without charging text", async () => { + const root = await bundled([ + { + path: "Small.dll", + bytes: pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\small.pdb", + }), + }, + ]); + const scanned = await inspect(root); + expect(scanned.pdb.paths).toContain("C:\\build\\small.pdb"); + expect(scanned.pdb.image_paths).toEqual([ + `${join(root, "app.dll")}/Small.dll`, + ]); + expect(scanned.text_members.skipped).toBe(0); + }); + + it("keeps a packaged PE header beyond the probe unresolved", async () => { + const original = pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\delayed.pdb", + }); + const image = Buffer.alloc(8192 + 512); + original.copy(image); + original.subarray(64, 512).copy(image, 8192); + image.writeUInt32LE(8192, 60); + const root = await bundled([{ path: "Delayed.dll", bytes: image }]); + const scanned = await inspect(root); + expect(scanned.pdb).toMatchObject({ failed: 1, paths: [] }); + expect(scanned.limitations.join("\n")).toContain("Delayed.dll"); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ subject: PACKAGED_NATIVE_IMAGES, count: 1 }), + ); + expect(scanned.coverage).toBe("unknown"); + }); +}); + +describe("ReleaseExposureReader native members named as maps", () => { + it("keeps a selected or listed native image named release.map out of map routing", async () => { + const root = await createTestTempDirectory("rea-release-map-selected-"); + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + elf.write("not a map\n", 16); + const file = join(root, "release.map"); + await writeFile(file, elf); + for (const scanned of [await inspect(file), await inspect(root)]) { + expect(scanned.native_images).toMatchObject([ + { path: file, format: "elf" }, + ]); + expect(scanned.debug_companions.paths).toEqual([]); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + skipped: 0, + unparsed: 0, + }); + expect(debugCompanionsStatus(scanned)).toBe("not_observed"); + } + }); + + it("keeps a selected package executable named release.map out of map routing", async () => { + const root = await createTestTempDirectory("rea-release-map-package-"); + const file = join(root, "release.map"); + await writeFile( + file, + cArchiveExecutable({ + carrier: "elf", + layout: "2.1", + pythonVersion: 310, + members: [ + { + name: "notes", + typeCode: "x", + compress: false, + payload: Buffer.from("plain\n"), + }, + ], + }), + ); + const scanned = await inspect(file); + expect(scanned.native_images).toMatchObject([ + { path: file, format: "elf" }, + ]); + expect(scanned.debug_companions.paths).toEqual([]); + expect(debugCompanionsStatus(scanned)).toBe("not_observed"); + }); + + it.each([ + ["a JavaScript map name", "payload.js.map"], + ["a linker map name", "payload.map"], + ])("reads a native member with %s by its bytes", async (_, name) => { + const root = await createTestTempDirectory("rea-release-map-native-"); + const file = join(root, "app"); + await writeFile( + file, + cArchiveExecutable({ + carrier: "elf", + layout: "2.1", + pythonVersion: 310, + members: [ + { + name, + typeCode: "x", + compress: true, + payload: pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\payload.pdb", + }), + }, + ], + }), + ); + const scanned = await inspect(root); + expect(scanned.pdb).toMatchObject({ + examined: 1, + failed: 0, + paths: ["C:\\build\\payload.pdb"], + }); + expect(scanned.pdb.image_paths).toEqual([`${file}/${name}`]); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ subject: PACKAGED_NATIVE_IMAGES, count: 1 }), + ); + expect(scanned.source_maps.unparsed).toBe(0); + expect(scanned.debug_companions.paths).toEqual([]); + }); + + it.each([ + [ + "a script's declared map", + "loader.js", + "//# sourceMappingURL=payload.dat\n", + ], + ["a page's script", "index.html", ''], + ])( + "reads a native member named as %s by its bytes", + async (_, name, text) => { + const root = await bundled([ + { path: name, bytes: Buffer.from(text) }, + { + path: "payload.dat", + bytes: pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\target.pdb", + }), + }, + ]); + const scanned = await inspect(root); + expect(scanned.pdb.paths).toContain("C:\\build\\target.pdb"); + expect(scanned.source_maps.skipped).toBe(0); + }, + ); + + it("reads short text that begins like a DOS header as text", async () => { + const root = await bundled([ + { path: "note.txt", bytes: Buffer.from(`MZ${"a".repeat(80)}`) }, + ]); + const scanned = await inspect(root); + expect(scanned.textLocations).toEqual([ + `${join(root, "app.dll")}/note.txt`, + ]); + expect(scanned.pdb).toMatchObject({ failed: 0, paths: [] }); + }); +}); + +describe("ReleaseExposureReader unexpanded package PE reads", () => { + it("reads a native member by its bytes when its name says text", async () => { + const root = await bundled([ + { + path: "payload.json", + bytes: pe(0x8664, 64, 0x0002, false, { + pdbPath: "C:\\build\\payload.pdb", + }), + }, + { path: "plain.json", bytes: Buffer.from('{"plain":true}') }, + ]); + const scanned = await inspect(root); + expect(scanned.pdb.paths).toContain("C:\\build\\payload.pdb"); + expect(scanned.pdb.image_paths).toEqual([ + `${join(root, "app.dll")}/payload.json`, + ]); + expect(scanned.textLocations).toEqual([ + `${join(root, "app.dll")}/plain.json`, + ]); + }); + + it.each(["payload.json", "payload.js.map"])( + "keeps a nested ASAR named %s unknown", + async (name) => { + const inner = asarArchive([ + { name: "app.js.map", bytes: embeddedSourceMap() }, + ]); + const root = await bundled([{ path: name, bytes: inner }]); + const scanned = await inspect(root); + expect(scanned.source_maps.embedded).toBe(0); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.limitations).toContain( + `Nested package ${join(root, "app.dll")}/${name} was not expanded.`, + ); + }, + ); + + it("retains each compressed duplicate entry across PE reads", async () => { + const root = await createTestTempDirectory("rea-release-duplicate-pe-"); + const paths = ["C:\\build\\first.pdb", "C:\\build\\second.pdb"]; + const file = join(root, "app"); + await writeFile( + file, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 310, + members: paths.map((pdbPath) => ({ + name: "same.dll", + typeCode: "b", + compress: true, + payload: Buffer.concat([ + pe(0x8664, 64, 0x0002, false, { pdbPath }), + Buffer.alloc(4096), + ]), + })), + }), + ); + const scanned = await inspect(file); + expect(scanned.pdb.paths).toEqual(paths); + expect(scanned.pdb).toMatchObject({ examined: 2, failed: 0 }); + expect(scanned.extractable).toMatchObject({ + readable_members: 2, + unreadable_members: 0, + }); + }); + + it("omits a packaged native image from the language readers visibly", async () => { + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + const root = await bundled([{ path: "libnative.so", bytes: elf }]); + const scanned = await inspect(root); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ + subject: PACKAGED_NATIVE_IMAGES, + count: 1, + }), + ); + expect(scanned.native_images.map((image) => image.path)).toEqual([ + join(root, "app.dll"), + ]); + }); + + it("leaves member coverage unknown when a packaged native image cannot be read", async () => { + const root = await createTestTempDirectory("rea-release-unread-native-"); + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "native.node", bytes: elf }]), + ); + createdReaders.wrap = (reader, path) => { + if (path !== join(root, "app.asar")) return reader; + const open = reader.open.bind(reader); + reader.open = (entry, signal) => + entry.path.endsWith("native.node") + ? Promise.reject(new Error("member read failed")) + : open(entry, signal); + return reader; + }; + try { + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.limitations).toContain( + "Did not probe app.asar/native.node for an embedded package.", + ); + expect(scanned.extractable.unreadable_members).toBe(1); + } finally { + createdReaders.wrap = undefined; + } + }); + + it("leaves member coverage unknown when an unclassified packaged member cannot be read", async () => { + const root = await bundled([ + { path: "payload.bin", bytes: Buffer.from([0x7f, 0x45, 0x4c, 0x46]) }, + ]); + createdReaders.wrap = (reader, path) => { + if (!path.endsWith("app.dll")) return reader; + const open = reader.open.bind(reader); + reader.open = (entry, signal) => + entry.path.endsWith("payload.bin") + ? Promise.reject(new Error("member read failed")) + : open(entry, signal); + return reader; + }; + try { + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.limitations).toContain( + `Could not read ${join(root, "app.dll", "payload.bin")} to rule out a native image or package.`, + ); + } finally { + createdReaders.wrap = undefined; + } + }); + + it("leaves member coverage unknown when a package cannot be listed", async () => { + const root = await bundled([{ path: "app.js", bytes: Buffer.from("1") }]); + createdReaders.wrap = (reader, path) => { + if (path.endsWith("app.dll")) + reader.entries = async function* () { + yield* []; + throw new Error("listing failed"); + }; + return reader; + }; + try { + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.extractable.unreadable_members).toBeGreaterThan(0); + } finally { + createdReaders.wrap = undefined; + } + }); +}); + +it("keeps encrypted text-named PyInstaller members unclassified", async () => { + const root = await createTestTempDirectory("rea-encrypted-data-"); + const path = join(root, "app"); + await writeFile( + path, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 309, + members: [ + { + name: "pyimod00_crypto_key", + typeCode: "m", + payload: Buffer.from("key"), + compress: false, + }, + { + name: "PYZ.pyz", + typeCode: "z", + payload: pyzArchive({ + magic: PYTHON_39_MAGIC, + modules: [ + { + name: "payload.json", + typeCode: 2, + body: Buffer.from("opaque"), + }, + ], + }), + compress: false, + }, + ], + }), + ); + const scanned = await inspect(path); + expect(scanned.coverage).toBe("unknown"); + expect(scanned.extractable.unreadable_members).toBeGreaterThan(0); +}); + +describe("ReleaseExposureReader probed FAT identity", () => { + const omittedNative = async (bytes: Buffer): Promise => { + const root = await bundled([{ path: "member.bin", bytes }]); + const scanned = await inspect(root); + return scanned.omitted.find( + (item) => item.subject === PACKAGED_NATIVE_IMAGES, + )?.count; + }; + + it("keeps a JVM class past the probe out of the packaged native images", async () => { + const bytes = javaClassFile(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES); + expect(bytes.length).toBeGreaterThan( + MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, + ); + await expect(omittedNative(bytes)).resolves.toBeUndefined(); + }); + + it("counts a universal image whose slice headers sit past the probe", async () => { + const slice = Buffer.alloc(64); + slice.writeUInt32BE(0xcffaedfe, 0); + const { bytes, slices } = universalMacho([ + { bytes: slice, cpuType: 0x0100000c, cpuSubtype: 0 }, + ]); + expect(slices[0]?.offset).toBeGreaterThanOrEqual( + MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, + ); + await expect(omittedNative(bytes)).resolves.toBe(1); + }); +}); + +describe("ReleaseExposureReader bounded listing and cleanup", () => { + it("reports sibling close failure with the completed scan and retains its owner", async () => { + const root = await createTestTempDirectory( + "rea-release-exposure-sibling-close-", + ); + const selected = join(root, "app.js"); + const companion = join(root, "app.pdb"); + await writeFile(selected, "const value = 1;"); + await writeFile(companion, "debug"); + const reader = new ReleaseExposureReader(); + directoryReads.closeHandles = []; + directoryReads.failClose = true; + try { + const result = await reader.inspect( + { path: selected }, + undefined, + ignoreText, + ); + if (result.ok) throw new Error("Expected a failed audit."); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain( + `directory beside the selected file: ${root}`, + ); + expect(result.error.diagnostics?.primary_error).toMatchObject({ + details: { + diagnostics: { scan: { debug_companions: { paths: [companion] } } }, + }, + }); + const handle = directoryReads.failedHandle; + expect(handle).toBeDefined(); + await expect(reader.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + directoryReads.failClose = false; + await expect(reader.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + // A failed Dir.close has no validity signal authorizing another native close. + expect(directoryReads.closeHandles).toEqual([handle]); + expect(result.error.cleanupIncomplete).toBe(true); + } finally { + directoryReads.failClose = false; + directoryReads.failedHandle = undefined; + directoryReads.closeHandles = []; + await reader.close().catch(() => undefined); + } + }); + + it("applies the caller's byte ceiling to on-disk PE reads", async () => { + const root = await createTestTempDirectory("rea-release-exposure-ceiling-"); + await writeFile( + join(root, "app.exe"), + pe(0x8664, 64, 0x0002, false, { pdbPath: "C:\\build\\app.pdb" }), + ); + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect( + { path: root, max_file_bytes: 64 }, + undefined, + ignoreText, + ); + if (!result.ok) throw result.error; + expect(result.value.pdb).toMatchObject({ failed: 1, paths: [] }); + expect(result.value.coverage).toBe("unknown"); + } finally { + await reader.close(); + } + }); + + it("stops reading a large parent directory at the sibling allowance", async () => { + const root = await createTestTempDirectory("rea-release-exposure-listing-"); + await Promise.all( + Array.from({ length: 4100 }, (_, index) => + writeFile(join(root, `f${String(index)}.dat`), ""), + ), + ); + await writeFile(join(root, "app"), "selected"); + directoryReads.count = 0; + const scanned = await inspect(join(root, "app")); + expect(directoryReads.count).toBeLessThanOrEqual(4097); + expect(scanned.debug_companions.skipped).toBe(1); + }); + + it.each([ + { + name: "an embedded-package probe", + selected: "lib.so", + write: async (root: string) => { + const elf = Buffer.alloc(64); + elf.set([0x7f, 0x45, 0x4c, 0x46], 0); + await writeFile(join(root, "lib.so"), elf); + }, + }, + { + name: "reading an oversized map prefix", + selected: "bundle.map", + write: async (root: string) => { + await writeFile( + join(root, "bundle.map"), + Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, 0x20), + ); + }, + }, + ])( + "reports and retains a descriptor whose close failed after $name", + async ({ selected, write }) => { + const root = await createTestTempDirectory("rea-release-exposure-fd-"); + await write(root); + const actual = await vi.importActual< + typeof import("./inventory/ArtifactInventory.js") + >("./inventory/ArtifactInventory.js"); + inventoryRead.impl = async (...args) => { + const inventory = await actual.inventoryArtifactInScope(...args); + regularFiles.failClose = (path) => path.endsWith(selected); + return inventory; + }; + regularFiles.closes = 0; + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect( + { path: root }, + undefined, + ignoreText, + ); + if (result.ok) throw new Error("expected cleanup-incomplete failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources.join("\n")).toContain(selected); + regularFiles.failUntil = regularFiles.closes + 1; + await expect(reader.close()).rejects.toSatisfy( + (error: unknown) => + (error as { cleanupResources?: readonly string[] }).cleanupResources + ?.join("\n") + .includes(selected) === true, + ); + await reader.close(); + } finally { + inventoryRead.impl = undefined; + regularFiles.failClose = undefined; + regularFiles.failUntil = 1; + await reader.close().catch(() => undefined); + } + }, + ); +}); + +describe("ReleaseExposureReader cleanup-bearing read failures", () => { + const cleanupFailure = ( + reason: "limit" | "format", + resource: string, + ): ArtifactReaderFailure => + ArtifactReaderFailure.withCleanup( + new ArtifactReaderFailure(reason, `${reason} failure: ${resource}`), + { reason: "close failed", resources: [resource] }, + ); + + it("does not read a cleanup-incomplete limit failure as an oversized member", async () => { + const root = await createTestTempDirectory("rea-release-exposure-limit-"); + await writeFile(join(root, "bundle.map"), "{}"); + const real = stableRead.impl; + stableRead.impl = async (...args) => { + if (args[0].endsWith("bundle.map")) + throw cleanupFailure("limit", args[0]); + return (real as NonNullable)(...args); + }; + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect( + { path: root }, + undefined, + ignoreText, + ); + if (result.ok) throw new Error("expected cleanup-incomplete failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources.join("\n")).toContain("bundle.map"); + } finally { + stableRead.impl = real; + await reader.close(); + } + }); + + it("reports what it found before a member's cleanup failed", async () => { + const root = await createTestTempDirectory("rea-release-exposure-stop-"); + await writeFile( + join(root, "a.js.map"), + JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "" }), + ); + await writeFile(join(root, "z.map"), "{}"); + const real = stableRead.impl; + stableRead.impl = async (...args) => { + if (args[0].endsWith("z.map")) throw cleanupFailure("limit", args[0]); + return (real as NonNullable)(...args); + }; + const reader = new ReleaseExposureReader(); + const interrupted: ReleaseExposureScan[] = []; + try { + const result = await reader.inspect( + { path: root }, + undefined, + ignoreText, + (scan) => interrupted.push(scan), + ); + if (result.ok) throw new Error("expected cleanup-incomplete failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(interrupted).toHaveLength(1); + expect(interrupted[0]?.coverage).toBe("unknown"); + expect(interrupted[0]?.source_maps.without_sources).toBe(1); + expect(interrupted[0]?.limitations[0]).toContain("unfinished cleanup"); + } finally { + stableRead.impl = real; + await reader.close(); + } + }); + + it("reports what it found before the scan was cancelled", async () => { + const root = await createTestTempDirectory("rea-release-exposure-cancel-"); + await writeFile( + join(root, "a.js.map"), + JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "" }), + ); + await writeFile(join(root, "b.txt"), "first\n"); + await writeFile(join(root, "c.txt"), "second\n"); + const controller = new AbortController(); + const reader = new ReleaseExposureReader(); + const interrupted: ReleaseExposureScan[] = []; + try { + const result = await reader.inspect( + { path: root }, + { signal: controller.signal }, + () => controller.abort(new Error("stop")), + (scan) => interrupted.push(scan), + ); + if (result.ok) throw new Error("expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(interrupted).toHaveLength(1); + expect(interrupted[0]?.coverage).toBe("unknown"); + expect(interrupted[0]?.source_maps.without_sources).toBe(1); + expect(interrupted[0]?.limitations[0]).toContain("cancelled"); + } finally { + await reader.close(); + } + }); + + it("does not read cleanup-incomplete inventory as unknown coverage", async () => { + const root = await createTestTempDirectory("rea-release-exposure-inv-"); + const selected = join(root, "app"); + await writeFile(selected, "selected"); + inventoryRead.impl = () => + Promise.reject(cleanupFailure("format", selected)); + const reader = new ReleaseExposureReader(); + try { + const result = await reader.inspect( + { path: selected }, + undefined, + ignoreText, + ); + if (result.ok) throw new Error("expected cleanup-incomplete failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(selected); + } finally { + inventoryRead.impl = undefined; + await reader.close(); + } + }); +}); + +const embeddedSourceMap = (): Buffer => + Buffer.from( + JSON.stringify({ + version: 3, + file: "app.js", + sources: ["app.js"], + names: [], + mappings: "", + sourcesContent: [`export const secret = "${PLANTED_SOURCE}";\n`], + }), + ); + +const asarArchive = ( + files: readonly { readonly name: string; readonly bytes: Buffer }[], +): Buffer => { + let offset = 0; + const table: Record = {}; + for (const { name, bytes } of files) { + table[name] = { size: bytes.length, offset: String(offset) }; + offset += bytes.length; + } + return Buffer.concat([ + asarHeaderBytes(JSON.stringify({ files: table })), + ...files.map(({ bytes }) => bytes), + ]); +}; + +const sourceMapArchive = (): Buffer => { + const map = embeddedSourceMap(); + const notes = Buffer.from( + `-----BEGIN PRIVATE KEY-----\n${PLANTED_KEY}\n-----END PRIVATE KEY-----\n`, + ); + return Buffer.concat([ + asarHeaderBytes( + JSON.stringify({ + files: { + dist: { + files: { + "app.js.map": { size: map.length, offset: "0" }, + "notes.txt": { size: notes.length, offset: String(map.length) }, + }, + }, + }, + }), + ), + map, + notes, + ]); +}; + +describe("ReleaseExposureReader same-name members", () => { + it("scans every same-name package member instead of the last one twice", async () => { + const root = await createTestTempDirectory("rea-release-same-name-"); + const archive = join(root, "app.asar"); + await writeFile( + archive, + asarArchive([{ name: "config.txt", bytes: Buffer.from("x") }]), + ); + const contents = new Map([ + ["first", "marker"], + ["second", "harmless"], + ]); + createdReaders.wrap = (source) => { + const list = source.entries.bind(source); + source.entries = async function* (signal) { + for await (const entry of list(signal)) { + if (entry.path !== "config.txt") { + yield entry; + continue; + } + for (const adapterKey of contents.keys()) + yield { ...entry, adapterKey }; + } + }; + source.open = (entry) => + Promise.resolve(Readable.from([contents.get(entry.adapterKey) ?? ""])); + return source; + }; + try { + const reader = new ReleaseExposureReader(); + const texts: string[] = []; + const result = await reader.inspect( + { path: archive }, + undefined, + (text) => { + texts.push(text); + }, + ); + await reader.close(); + if (!result.ok) throw result.error; + expect(texts).toEqual(expect.arrayContaining(["harmless", "marker"])); + } finally { + createdReaders.wrap = undefined; + } + }); +}); + +describe("ReleaseExposureReader member identity", () => { + it("reads a compressed same-name member after an empty stored one", async () => { + const root = await createTestTempDirectory("rea-release-empty-first-"); + const file = join(root, "app"); + await writeFile( + file, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 310, + members: [ + { + name: "config.txt", + typeCode: "x", + compress: false, + payload: Buffer.alloc(0), + }, + { + name: "config.txt", + typeCode: "x", + compress: true, + payload: Buffer.from("credential-marker"), + }, + ], + }), + ); + const reader = new ReleaseExposureReader(); + const texts: string[] = []; + const result = await reader.inspect({ path: file }, undefined, (text) => { + texts.push(text); + }); + await reader.close(); + if (!result.ok) throw result.error; + expect(texts).toContain("credential-marker"); + }); + + it.each([ + ["app.js", "debug.json", { embedded: 1, skipped: 0 }], + ["z.js", "a.json", { embedded: 0, skipped: 1 }], + ])( + "follows %s to a differently named local map %s", + async (script, map, expected) => { + const root = await createTestTempDirectory("rea-release-named-map-"); + await writeFile( + join(root, script), + `const value = 1;\n//# sourceMappingURL=${map}\n`, + ); + await writeFile(join(root, map), embeddedSourceMap()); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject(expected); + }, + ); + + it.each([ + ["app.js", { embedded: 0, unparsed: 1, skipped: 0 }], + ["z.js", { embedded: 0, unparsed: 0, skipped: 1 }], + ])( + "keeps a malformed assets.map that %s declares unresolved", + async (script, expected) => { + const root = await createTestTempDirectory("rea-release-generic-map-"); + await writeFile( + join(root, script), + "const value = 1;\n//# sourceMappingURL=assets.map\n", + ); + await writeFile(join(root, "assets.map"), "not a map\n"); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject(expected); + expect(scanned.debug_companions.paths).toEqual([]); + }, + ); + + it("keeps an undeclared malformed assets.map a linker map", async () => { + const root = await createTestTempDirectory("rea-release-linker-map-"); + await writeFile(join(root, "assets.map"), "not a map\n"); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + expect(scanned.debug_companions.paths).toHaveLength(1); + }); + + it("counts a declared map target that never ships as skipped", async () => { + const root = await createTestTempDirectory("rea-release-missing-map-"); + await writeFile( + join(root, "app.js"), + "const value = 1;\n//# sourceMappingURL=debug.json\n", + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + }); + + it.each([ + ["app.js", "debug.json", { embedded: 1, skipped: 0 }], + ["z.js", "a.json", { embedded: 0, skipped: 1 }], + ])( + "follows %s through a file: URL to the inventoried member %s", + async (script, map, expected) => { + const root = await createTestTempDirectory("rea-release-file-url-map-"); + const url = pathToFileURL(join(root, map)).href; + await writeFile( + join(root, script), + `const value = 1;\n//# sourceMappingURL=${url}\n`, + ); + await writeFile(join(root, map), embeddedSourceMap()); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject(expected); + }, + ); + + it.each(["debug.json", "debug.js.map"])( + "counts a file: URL to %s outside the installation as skipped", + async (map) => { + const root = await createTestTempDirectory("rea-release-file-url-gone-"); + const outside = await createTestTempDirectory( + "rea-release-file-url-out-", + ); + const url = pathToFileURL(join(outside, map)).href; + await writeFile( + join(root, "app.js"), + `const value = 1;\n//# sourceMappingURL=${url}\n`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + }, + ); + + it("counts a malformed file: URL as skipped and ignores a remote map", async () => { + const root = await createTestTempDirectory("rea-release-file-url-bad-"); + await writeFile( + join(root, "bad.js"), + "const value = 1;\n//# sourceMappingURL=file:///release%2Fdebug.json\n", + ); + await writeFile( + join(root, "remote.js"), + "const value = 2;\n//# sourceMappingURL=https://example.com/debug.json\n", + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + }); +}); + +const malformedLeaf = (mappings: string) => ({ + version: 3, + sources: ["b.js"], + names: [], + mappings, +}); + +const debugCompanionsStatus = (scan: ReleaseExposureScan) => + assembleReleaseExposureReport(scan, { + electron_family: "not_electron", + fuse_images: [], + go_images: [], + rust_images: [], + credential_markers: { hits: [], omitted_hits: 0 }, + }).checks.find((check) => check.id === "native.debug_companions")?.status; + +const credentialMarkersStatus = (scan: ReleaseExposureScan) => + assembleReleaseExposureReport(scan, { + electron_family: "not_electron", + fuse_images: [], + go_images: [], + rust_images: [], + credential_markers: { hits: [], omitted_hits: 0 }, + }).checks.find((check) => check.id === "text.credential_markers")?.status; + +const sourceMapSourcesStatus = (scan: ReleaseExposureScan) => + assembleReleaseExposureReport(scan, { + electron_family: "not_electron", + fuse_images: [], + go_images: [], + rust_images: [], + credential_markers: { hits: [], omitted_hits: 0 }, + }).checks.find((check) => check.id === "javascript.source_map_sources") + ?.status; + +const inspect = async (path: string, admission?: AsarHeaderAdmission) => { + const reader = new ReleaseExposureReader(admission); + try { + const textLocations: string[] = []; + const result = await reader.inspect({ path }, undefined, (_, location) => { + textLocations.push(location); + }); + if (!result.ok) throw result.error; + return { ...result.value, textLocations }; + } finally { + await reader.close(); + } +}; + +describe("ReleaseExposureReader inline map reads", () => { + const map = Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64"); + + const dataUrl = `data:application/json;base64,${map}`; + + it.each([ + ["a classic script", "`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("reads an inline map in an HTML member of an ASAR", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + const path = join(root, "app.asar"); + await writeFile( + path, + asarArchive([ + { + name: "index.html", + bytes: Buffer.from( + ``, + ), + }, + ]), + ); + expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed"); + }); + + it("does not read a map in a data block or a script that loads by URL", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + await writeFile(join(root, "app.js"), "run();\n"); + await writeFile( + join(root, "index.html"), + `` + + ``, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("not_observed"); + }); + + it("keeps HTML map coverage unknown for scripts it did not read", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + const path = join(root, "index.html"); + await writeFile( + path, + "".repeat(HTML_INLINE_SCRIPT_LOCATION_LIMIT + 2), + ); + const past = await inspect(path); + expect(past.source_maps.skipped).toBe(2); + expect(sourceMapSourcesStatus(past)).toBe("unknown"); + await writeFile( + path, + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20), + ); + expect((await inspect(path)).source_maps.skipped).toBe(1); + }); + + const bunExecutable = (sourcemap: Buffer): Buffer => + elfBunSection( + bunPrefixedPayload( + bunModuleGraphPayload({ + modules: [ + { + name: "/$bunfs/root/main.js", + contents: Buffer.from("run();\n"), + sourcemap, + }, + ], + }), + ), + ); + + it.each([ + ["a selected executable", (root: string) => join(root, "server")], + ["an executable in a directory", (root: string) => root], + ])("reads a Bun sourcemap region in %s", async (_, selected) => { + const root = await createTestTempDirectory("rea-release-exposure-bun-"); + await writeFile( + join(root, "server"), + bunExecutable(Buffer.from(map, "base64")), + ); + const scanned = await inspect(selected(root)); + expect(scanned.source_maps).toMatchObject({ embedded: 1, unparsed: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("keeps a binary Bun sourcemap region unresolved instead of a companion", async () => { + const root = await createTestTempDirectory("rea-release-exposure-bun-"); + await writeFile( + join(root, "server"), + bunExecutable(Buffer.from([1, 0, 0, 0, 0xff, 0xfe, 0, 0])), + ); + const scanned = await inspect(join(root, "server")); + expect(scanned.source_maps.unparsed).toBe(1); + expect(scanned.debug_companions.paths).toEqual([]); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("reads a percent-escaped base64 inline map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-map-"); + const escaped = `%${map.charCodeAt(0).toString(16)}${map.slice(1)}`; + await writeFile( + join(root, "app.js"), + `//# sourceMappingURL=data:application/json;base64,${escaped}\n`, + ); + const { source_maps: maps } = await inspect(root); + expect(maps.embedded).toBe(1); + expect(maps.unparsed).toBe(0); + }); + + it("reads the inline map of an extensionless SEA entry in a directory", async () => { + const root = await createTestTempDirectory("rea-release-exposure-map-"); + await writeFile( + join(root, "app"), + nodeSeaElf({ + codePath: "entry", + main: `//# sourceMappingURL=data:application/json;base64,${map}\n`, + }), + ); + expect((await inspect(root)).source_maps.embedded).toBe(1); + }); + + it("reads a SEA entry named .map as JavaScript, not as a linker map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-map-"); + await writeFile( + join(root, "app"), + nodeSeaElf({ + codePath: "boot.map", + main: `//# sourceMappingURL=data:application/json;base64,${map}\n`, + }), + ); + const scanned = await inspect(root); + expect(scanned.source_maps.embedded).toBe(1); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); +}); + +describe("ReleaseExposureReader Bun module loaders", () => { + const inlineMap = Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64"); + + it.each([ + ["js", 1, "exposed"], + ["text", 13, "not_observed"], + ] as const)( + "reads an extensionless Bun module with the %s loader by its loader", + async (_, loader, status) => { + const root = await createTestTempDirectory("rea-release-exposure-bun-"); + await writeFile( + join(root, "server"), + elfBunSection( + bunPrefixedPayload( + bunModuleGraphPayload({ + modules: [ + { + name: "/$bunfs/root/release", + loader, + contents: Buffer.from( + `run();\n//# sourceMappingURL=data:application/json;base64,${inlineMap}\n`, + ), + }, + ], + }), + ), + ), + ); + expect(sourceMapSourcesStatus(await inspect(root))).toBe(status); + }, + ); +}); + +describe("ReleaseExposureReader byte-order-marked maps", () => { + it.each([ + ["utf-16le", [0xff, 0xfe]], + ["utf-16be", [0xfe, 0xff]], + ] as const)( + "reads a generic map marked %s as a source map", + async (encoding, mark) => { + const root = await createTestTempDirectory("rea-release-exposure-bom-"); + const json = JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }); + const body = Buffer.from(json, "utf16le"); + if (encoding === "utf-16be") body.swap16(); + await writeFile( + join(root, "symbols.map"), + Buffer.concat([Buffer.from(mark), body]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, unparsed: 0 }); + expect(scanned.debug_companions.paths).toEqual([]); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }, + ); + + it("keeps a generic map marked utf-32 unresolved", async () => { + const root = await createTestTempDirectory("rea-release-exposure-bom-"); + await writeFile( + join(root, "symbols.map"), + Buffer.concat([ + Buffer.from([0xff, 0xfe, 0, 0]), + Buffer.from("{\0\0\0", "latin1"), + ]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ unparsed: 1, embedded: 0 }); + expect(scanned.debug_companions.paths).toEqual([]); + }); +}); + +describe("ReleaseExposureReader text-named scripts past the allowance", () => { + it("keeps coverage unknown for a text-named script past the spent text allowance", async () => { + const root = await createTestTempDirectory("rea-release-exposure-shebang-"); + const members = + MAX_RELEASE_EXPOSURE_TEXT_BYTES / MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES; + for (let index = 0; index < members; index += 1) + await writeFile( + join(root, `a${String(index)}.txt`), + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, 0x20), + ); + await writeFile( + join(root, "z-runner.txt"), + `#!/usr/bin/env node\nrun();\n//# sourceMappingURL=data:application/json;base64,${embeddedSourceMap().toString("base64")}\n`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it.each([1, 2])( + "keeps coverage unknown for a text-named script with %i allowance bytes left", + async (left) => { + const root = await createTestTempDirectory( + "rea-release-exposure-shebang-", + ); + const members = + MAX_RELEASE_EXPOSURE_TEXT_BYTES / + MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES; + for (let index = 0; index < members; index += 1) + await writeFile( + join(root, `a${String(index)}.txt`), + Buffer.alloc( + MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES - + (index === members - 1 ? left : 0), + 0x20, + ), + ); + await writeFile( + join(root, "z-runner.txt"), + `#!/usr/bin/env node\nrun();\n//# sourceMappingURL=data:application/json;base64,${embeddedSourceMap().toString("base64")}\n`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }, + ); +}); + +describe("ReleaseExposureReader Node shebang scripts", () => { + const dataUrl = `data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64")}`; + + const script = (shebang: string): string => + `${shebang}\nrun();\n//# sourceMappingURL=${dataUrl}\n`; + + const shebangRoot = async (): Promise => + createTestTempDirectory("rea-release-exposure-shebang-"); + + it.each([ + ["#!/usr/bin/env node"], + ["#!/usr/bin/env node\r"], + ["#!/usr/bin/env -S node --no-warnings"], + ["#!/usr/bin/env -S -u NODE_OPTIONS node"], + ["#!/usr/bin/env -u NODE_OPTIONS node"], + ["#!/usr/bin/env -C /srv/app node"], + ["#!/usr/bin/env -Snode --no-warnings"], + ["#!/usr/bin/env --unset NODE_OPTIONS -- node"], + ["#!/usr/bin/env -S -- MODE=production node"], + ["#!/usr/bin/env -S NODE_OPTIONS='--conditions development' node"], + ['#!/usr/bin/env -S NODE_OPTIONS="--conditions development" node'], + ["#!/usr/bin/env -S\tNODE_OPTIONS='a\tb' \"node\" --no-warnings"], + ['#!/usr/bin/env -S MODE="a\\_b" node # launcher'], + ["#!/usr/local/bin/node --no-warnings"], + ["#! /usr/bin/nodejs"], + ])("reads the map of %j in a directory", async (shebang) => { + const root = await shebangRoot(); + await writeFile(join(root, "runner"), script(shebang)); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("reads the map of a selected file", async () => { + const path = join(await shebangRoot(), "runner"); + await writeFile(path, script("#!/usr/bin/env node")); + expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed"); + }); + + it.each(["runner.txt", "runner.md"])( + "reads the map of a selected text file %s", + async (name) => { + const path = join(await shebangRoot(), name); + await writeFile(path, script("#!/usr/bin/env node")); + const scanned = await inspect(path); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }, + ); + + it("reads the map of a text file in a directory", async () => { + const root = await shebangRoot(); + await writeFile(join(root, "runner.txt"), script("#!/usr/bin/env node")); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it.each(["app.js.map", "debug.map"])( + "keeps a sibling %s a selected file declares unknown", + async (map) => { + const root = await shebangRoot(); + const path = join(root, "runner"); + await writeFile( + path, + `#!/usr/bin/env node\nrun();\n//# sourceMappingURL=${map}\n`, + ); + await writeFile(join(root, map), embeddedSourceMap()); + const scanned = await inspect(path); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }, + ); + + it("reads the map of an ASAR member", async () => { + const path = join(await shebangRoot(), "app.asar"); + await writeFile( + path, + asarArchive([ + { + name: "runner", + bytes: Buffer.from(script("#!/usr/bin/env node")), + }, + ]), + ); + expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed"); + }); + + it("keeps coverage unknown for a script past the text allowance", async () => { + const root = await shebangRoot(); + await writeFile( + join(root, "runner"), + `${script("#!/usr/bin/env node")}${" ".repeat( + MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, + )}`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("keeps coverage unknown for a launcher past the spent text allowance", async () => { + const root = await shebangRoot(); + const members = + MAX_RELEASE_EXPOSURE_TEXT_BYTES / MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES; + for (let index = 0; index < members; index += 1) + await writeFile( + join(root, `a${String(index)}.txt`), + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, 0x20), + ); + await writeFile(join(root, "z-runner"), script("#!/usr/bin/env node")); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("keeps coverage unknown for a script that does not decode", async () => { + const root = await shebangRoot(); + await writeFile( + join(root, "runner"), + Buffer.concat([ + Buffer.from("#!/usr/bin/env node\n"), + Buffer.from(script("").repeat(2), "utf16le"), + ]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("does not count a shebang script an HTML file loads later as unread", async () => { + const root = await shebangRoot(); + await writeFile(join(root, "app"), script("#!/usr/bin/env node")); + await writeFile(join(root, "index.html"), ''); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it.each([ + ["#!/bin/sh"], + ["#!/usr/bin/env python3"], + ["#!/usr/bin/env nodemon"], + ["#!/opt/node/bin/python3 node"], + ["#!/usr/bin/env -S LABEL='a node b' deno"], + ["#!/usr/bin/env -S MODE=a\\_deno node"], + ["#!/usr/bin/env -S MODE='unterminated node"], + ])("does not read %j as JavaScript", async (shebang) => { + const root = await shebangRoot(); + await writeFile(join(root, "runner"), script(shebang)); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + }); +}); + +describe("ReleaseExposureReader env -S programs named by a variable", () => { + const script = (shebang: string): string => + `${shebang}\nrun();\n//# sourceMappingURL=data:application/json;base64,${embeddedSourceMap().toString("base64")}\n`; + + const shebangRoot = async (): Promise => + createTestTempDirectory("rea-release-exposure-shebang-"); + + it.each([ + ["#!/usr/bin/env -S ${NODE_BIN}"], + ['#!/usr/bin/env -S "${NODE_BIN}" --no-warnings'], + ["#!/usr/bin/env -S MODE=production /opt/${RUNTIME}"], + ])("leaves the maps of %j unassessed", async (shebang) => { + const root = await shebangRoot(); + await writeFile(join(root, "runner"), script(shebang)); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it.each([ + ["#!/usr/bin/env -S '${NODE_BIN}'"], + ["#!/usr/bin/env -S \\${NODE_BIN}"], + ["#!/usr/bin/env -S ${RUNTIME_DIR}/bin/deno"], + ])( + "does not read the literal or resolved program of %j as Node", + async (shebang) => { + const root = await shebangRoot(); + await writeFile(join(root, "runner"), script(shebang)); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + }, + ); +}); + +describe("ReleaseExposureReader unreadable launchers", () => { + it("keeps coverage unknown for an extensionless launcher that cannot be read", async () => { + const root = await createTestTempDirectory("rea-release-exposure-shebang-"); + await writeFile( + join(root, "runner"), + "#!/usr/bin/env node\nrun();\n//# sourceMappingURL=runner.js.map\n", + ); + const real = stableRead.impl; + stableRead.impl = async (...args) => { + if (String(args[0]).endsWith("runner")) + throw new Error("unreadable member"); + if (real === undefined) throw new Error("missing stable read"); + return real(...args); + }; + try { + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + } finally { + stableRead.impl = real; + } + }); +}); + +describe("ReleaseExposureReader Node shebang .map scripts", () => { + it("counts Node-shebang map reads against the map allowance", async () => { + const root = await createTestTempDirectory( + "rea-release-shebang-map-budget-", + ); + for (let i = 0; i < MAX_RELEASE_EXPOSURE_SOURCE_MAPS; i += 1) + await writeFile( + join(root, `${String(i).padStart(3, "0")}.map`), + "#!/usr/bin/env node\nrun();\n", + ); + await writeFile(join(root, "zzz.js.map"), embeddedSourceMap()); + const scanned = await inspect(root); + expect(scanned.source_maps.skipped).toBeGreaterThan(0); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + const mapUrl = `data:application/json;base64,${embeddedSourceMap().toString( + "base64", + )}`; + + it.each([true, false])( + "reads the inline map of a runner.map, selected: %s", + async (selected) => { + const root = await createTestTempDirectory( + "rea-release-exposure-shebang-map-", + ); + const path = join(root, "runner.map"); + await writeFile( + path, + `#!/usr/bin/env node\nrun();\n//# sourceMappingURL=${mapUrl}\n`, + ); + const scanned = await inspect(selected ? path : root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(scanned.debug_companions.paths).toEqual([]); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }, + ); + + it.each([true, false])( + "leaves a runner.map whose env -S program is a variable unassessed, selected: %s", + async (selected) => { + const root = await createTestTempDirectory( + "rea-release-exposure-shebang-map-", + ); + const path = join(root, "runner.map"); + await writeFile( + path, + `#!/usr/bin/env -S \${NODE_BIN}\nrun();\n//# sourceMappingURL=${mapUrl}\n`, + ); + const scanned = await inspect(selected ? path : root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(scanned.debug_companions.paths).toEqual([]); + expect(scanned.debug_companions.skipped).toBe(1); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }, + ); + + it("leaves the credential markers of an oversized runner.map whose env -S program is a variable unknown", async () => { + const root = await createTestTempDirectory( + "rea-release-exposure-shebang-map-", + ); + const map = Buffer.alloc(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES + 1, 0x20); + map.write("#!/usr/bin/env -S ${NODE_BIN}\nrun();\n"); + await writeFile(join(root, "runner.map"), map); + await writeFile(join(root, "notes.txt"), "plain text\n"); + const scanned = await inspect(root); + expect(scanned.text_members.skipped).toBe(1); + expect(credentialMarkersStatus(scanned)).toBe("unknown"); + }); +}); + +describe("ReleaseExposureReader resource admission", () => { + it("holds one scope admission for a whole audit", async () => { + const root = await createTestTempDirectory("rea-release-exposure-scope-"); + await writeFile(join(root, "app.js"), "run();\n"); + const run = ArtifactResourceScope.prototype.run; + let depth = 0; + let deepest = 0; + const admitted = vi + .spyOn(ArtifactResourceScope.prototype, "run") + .mockImplementation(async function ( + this: ArtifactResourceScope, + operation, + ) { + depth += 1; + deepest = Math.max(deepest, depth); + try { + return await run.call(this, operation); + } finally { + depth -= 1; + } + } as typeof run); + try { + await inspect(root); + await inspect(join(root, "app.js")); + } finally { + admitted.mockRestore(); + } + expect(deepest).toBe(1); + }); +}); + +describe("ReleaseExposureReader map target allowance", () => { + it("keeps a map past the target allowance unassessed", async () => { + const root = await createTestTempDirectory("rea-release-exposure-maps-"); + const folder = join( + root, + ...Array.from({ length: 4 }, () => "d".repeat(200)), + ); + await mkdir(folder, { recursive: true }); + const declarations = Array.from( + { length: Math.ceil(MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES / 800) + 1 }, + (_, index) => `//# sourceMappingURL=m${String(index)}`, + ); + await writeFile( + join(folder, "a.js"), + `${declarations.join("\n")}\n//# sourceMappingURL=z\n`, + ); + await writeFile( + join(folder, "z"), + JSON.stringify({ + version: 3, + sources: ["s.ts"], + sourcesContent: ["x"], + mappings: "", + }), + ); + const scanned = await inspect(root); + expect(scanned.source_maps.embedded).toBe(0); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("does not read a .map past the target allowance as a linker map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-maps-"); + const folder = join( + root, + ...Array.from({ length: 4 }, () => "d".repeat(200)), + ); + await mkdir(folder, { recursive: true }); + const declarations = Array.from( + { length: Math.ceil(MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES / 800) + 1 }, + (_, index) => `//# sourceMappingURL=m${String(index)}`, + ); + await writeFile( + join(folder, "a.js"), + `${declarations.join("\n")}\n//# sourceMappingURL=z.map\n`, + ); + await writeFile(join(folder, "z.map"), "not a map\n"); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([]); + expect(scanned.debug_companions.skipped).toBeGreaterThan(0); + expect(debugCompanionsStatus(scanned)).toBe("unknown"); + expect(scanned.source_maps.skipped).toBeGreaterThan(0); + }); + + it("does not read a .map script past the target allowance as a linker map", async () => { + const baseBytes = 64 * 1024; + const references = Array.from( + { length: MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES / baseBytes + 1 }, + (_, index) => ``, + ); + const root = await createTestTempDirectory("rea-release-exposure-maps-"); + await writeFile( + join(root, "a.html"), + `${references.join("")}`, + ); + await writeFile(join(root, "b.html"), ''); + await writeFile(join(root, "boot.map"), "run();\n"); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([]); + expect(debugCompanionsStatus(scanned)).toBe("unknown"); + }); +}); + +describe("ReleaseExposureReader script roles over a .map name", () => { + it("reads a .map script an HTML file loads as JavaScript", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + const map = Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64"); + await writeFile( + join(root, "index.html"), + '', + ); + await writeFile( + join(root, "main.map"), + `//# sourceMappingURL=data:application/json;base64,${map}\n`, + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); + + it("reads a .map script target whose bytes are a source map as a map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + await writeFile( + join(root, "index.html"), + '', + ); + await writeFile( + join(root, "main.map"), + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); + + it("reads a UTF-16 .map script target from the text that recognized it", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + await writeFile( + join(root, "index.html"), + '', + ); + const map = JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }); + await writeFile( + join(root, "main.map"), + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(map, "utf16le")]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); + + it("retracts a linker-map guess when a later page loads the .map as a script", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + await writeFile(join(root, "app.map"), "run();\n"); + await writeFile( + join(root, "index.html"), + '', + ); + const archive = join( + await createTestTempDirectory("rea-release-exposure-html-"), + "app.asar", + ); + await writeFile( + archive, + asarArchive([ + { name: "app.map", bytes: Buffer.from("run();\n") }, + { + name: "index.html", + bytes: Buffer.from(''), + }, + ]), + ); + for (const target of [root, archive]) { + const scanned = await inspect(target); + expect(scanned.debug_companions.paths).toEqual([]); + expect(debugCompanionsStatus(scanned)).not.toBe("exposed"); + expect(scanned.source_maps.skipped).toBeGreaterThan(0); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + } + }); +}); + +describe("ReleaseExposureReader HTML script references", () => { + const inlineMap = `//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64")}\n`; + + const directory = async ( + files: Readonly>, + ): Promise => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + for (const [name, text] of Object.entries(files)) + await writeFile(join(root, name), text); + return root; + }; + + it("reads the inline map of an extensionless script an HTML file loads", async () => { + const scanned = await inspect( + await directory({ + "index.html": '', + main: inlineMap, + }), + ); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("reads an extensionless script an ASAR's HTML loads", async () => { + const root = await createTestTempDirectory("rea-release-exposure-html-"); + const path = join(root, "app.asar"); + await writeFile( + path, + asarArchive([ + { + name: "index.html", + bytes: Buffer.from(''), + }, + { name: "main", bytes: Buffer.from(inlineMap) }, + ]), + ); + expect(sourceMapSourcesStatus(await inspect(path))).toBe("exposed"); + }); + + it("resolves a script against the document base", async () => { + const root = await directory({ + "index.html": '', + }); + await mkdir(join(root, "static")); + await writeFile(join(root, "static", "main"), inlineMap); + expect(sourceMapSourcesStatus(await inspect(root))).toBe("exposed"); + }); + + it("does not count a script two pages share as unread", async () => { + const scanned = await inspect( + await directory({ + "a.html": '', + main: inlineMap, + "z.html": '', + }), + ); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + }); + + it("reads the scripts of a standalone SVG", async () => { + const scanned = await inspect( + await directory({ + "screen.svg": ``, + }), + ); + expect(scanned.source_maps.embedded).toBe(1); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("finds nothing in an SVG without scripts", async () => { + const scanned = await inspect( + await directory({ + "icon.svg": + '', + }), + ); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + }); + + it("still reads a page another page loads as a script as HTML", async () => { + const scanned = await inspect( + await directory({ + "a.html": '', + "z.html": ``, + }), + ); + expect(scanned.source_maps.embedded).toBe(1); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); + + it("keeps a script past the target allowance unassessed", async () => { + const baseBytes = 64 * 1024; + const references = Array.from( + { length: MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES / baseBytes + 1 }, + (_, index) => ``, + ); + const scanned = await inspect( + await directory({ + "a.html": `${references.join("")}`, + "b.html": '', + main: inlineMap, + }), + ); + expect(scanned.source_maps).toMatchObject({ + embedded: 0, + skipped: references.length + 1, + }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("keeps coverage unknown for a script read before its HTML file", async () => { + const scanned = await inspect( + await directory({ + app: inlineMap, + "index.html": '', + }), + ); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("keeps coverage unknown for a named script beside a selected HTML file", async () => { + const page = ''; + const root = await directory({ "app.js": inlineMap, "index.html": page }); + const scanned = await inspect(join(root, "index.html")); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it.each([ + ["a missing member", "main"], + ["a path from a root the audit cannot place", "/main"], + ["a scheme the audit cannot resolve", "app://./main"], + ])("keeps coverage unknown for %s", async (_, src) => { + const scanned = await inspect( + await directory({ "index.html": `` }), + ); + expect(scanned.source_maps.skipped).toBe(1); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + }); + + it("leaves coverage alone for remote, named, and data-block references", async () => { + const scanned = await inspect( + await directory({ + "app.js": "run();\n", + "index.html": + '' + + '' + + '', + }), + ); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("not_observed"); + }); +}); + +describe("ReleaseExposureReader cumulative text allowance", () => { + const MEMBERS_PAST_ALLOWANCE = 20; + const MEMBERS_IN_ALLOWANCE = + MAX_RELEASE_EXPOSURE_TEXT_BYTES / MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES; + + /** Compressed text members, then one small member the allowance decides. */ + const packageRoot = async (bytes: Buffer): Promise => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture([ + ...Array.from({ length: MEMBERS_PAST_ALLOWANCE }, (_, index) => ({ + path: `m${String(index)}.txt`, + bytes, + compressed: true, + })), + { path: "last.txt", bytes: Buffer.from("hello\n") }, + ]).bytes, + ); + return root; + }; + + const inspectCountingOpens = async ( + root: string, + open?: (entry: ArtifactEntry) => Readable | undefined, + ) => { + let opened = 0; + createdReaders.wrap = (reader, path) => { + if (path !== join(root, "app.dll")) return reader; + const openEntry = reader.open.bind(reader); + reader.open = (entry, signal) => { + if (entry.path.startsWith("m")) opened += 1; + const replaced = open?.(entry); + return replaced === undefined + ? openEntry(entry, signal) + : Promise.resolve(replaced); + }; + return reader; + }; + try { + return { scanned: await inspect(root), opened: () => opened }; + } finally { + createdReaders.wrap = undefined; + } + }; + + it("charges oversized package text reads to the allowance", async () => { + const { scanned, opened } = await inspectCountingOpens( + await packageRoot( + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20), + ), + ); + expect(opened()).toBeLessThanOrEqual(MEMBERS_IN_ALLOWANCE); + expect(scanned.textLocations).toEqual([]); + expect(scanned.text_members).toEqual({ + scanned: 0, + skipped: MEMBERS_PAST_ALLOWANCE + 1, + }); + }); + + it("charges package text that did not decode to the allowance", async () => { + const utf32 = Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES); + Buffer.from([0xff, 0xfe, 0, 0]).copy(utf32); + const { scanned, opened } = await inspectCountingOpens( + await packageRoot(utf32), + ); + expect(opened()).toBe(MEMBERS_IN_ALLOWANCE); + expect(scanned.textLocations).toEqual([]); + }); + + it("charges the bytes of a package read that failed partway", async () => { + const { scanned, opened } = await inspectCountingOpens( + await packageRoot(Buffer.from("unread\n")), + (entry) => + entry.path.startsWith("m") + ? Readable.from( + (function* (): Generator { + yield Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES); + throw new Error("damaged member"); + })(), + ) + : undefined, + ); + expect(opened()).toBe(MEMBERS_IN_ALLOWANCE); + expect(scanned.textLocations).toEqual([]); + }); + + it("keeps an unclassified package member past the allowance unknown", async () => { + const root = await createTestTempDirectory("rea-release-exposure-text-"); + const inner = asarArchive([ + { name: "app.js.map", bytes: embeddedSourceMap() }, + ]); + await writeFile( + join(root, "app.dll"), + dotnetBundleFixture([ + ...Array.from({ length: MEMBERS_PAST_ALLOWANCE }, (_, index) => ({ + path: `m${String(index)}.txt`, + bytes: Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, 0x20), + compressed: true, + })), + { path: "payload.json", bytes: inner }, + ]).bytes, + ); + const scanned = await inspect(root); + expect(scanned.coverage).toBe("unknown"); + expect(sourceMapSourcesStatus(scanned)).toBe("unknown"); + expect(scanned.limitations).toContain( + `Did not read ${join(root, "app.dll")}/payload.json past the text allowance to rule out a native image or package.`, + ); + }); + + it("charges only the probe for an oversized file on disk", async () => { + const root = await createTestTempDirectory("rea-release-exposure-disk-"); + for (let index = 0; index < MEMBERS_PAST_ALLOWANCE; index += 1) + await writeFile( + join(root, `m${String(index)}.txt`), + Buffer.alloc(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES + 1, 0x20), + ); + await writeFile(join(root, "z.txt"), "hello\n"); + const scanned = await inspect(root); + expect(scanned.textLocations).toEqual([join(root, "z.txt")]); + expect(scanned.text_members).toEqual({ + scanned: 1, + skipped: MEMBERS_PAST_ALLOWANCE, + }); + }); +}); + +describe("ReleaseExposureReader unfollowed map links", () => { + const SCRIPT = "const value = 1;\n//# sourceMappingURL=app.js.map\n"; + const EMBEDDED_PAYLOAD = `const value = 1;\n//# sourceMappingURL=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["a.ts"], + sourcesContent: ["const value = 1;"], + mappings: "", + }), + ).toString("base64")}\n`; + + it.skipIf(process.platform === "win32")( + "counts a map symlink in a directory as skipped once, declared or not", + async () => { + for (const script of ["const value = 1;\n", SCRIPT]) { + const root = await createTestTempDirectory("rea-release-map-link-"); + await writeFile(join(root, "payload"), "not read\n"); + await writeFile(join(root, "app.js"), script); + await symlink("payload", join(root, "app.js.map")); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ subject: "source maps", count: 1 }), + ); + } + }, + ); + + it("counts a map link in an ASAR as skipped", async () => { + const root = await createTestTempDirectory("rea-release-asar-map-link-"); + const archive = join(root, "app.asar"); + const payload = Buffer.from("not read\n"); + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes( + JSON.stringify({ + files: { + "app.js.map": { link: "payload" }, + payload: { size: payload.length, offset: "0" }, + }, + }), + ), + payload, + ]), + ); + const scanned = await inspect(archive); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ subject: "source maps", count: 1 }), + ); + }); + + it.skipIf(process.platform === "win32")( + "counts a directory link whose name can hold a map directive as skipped", + async () => { + for (const name of ["app.js", "index.html", "screen.svg", "app.css"]) { + const root = await createTestTempDirectory("rea-release-script-link-"); + await writeFile(join(root, "payload"), EMBEDDED_PAYLOAD); + await symlink("payload", join(root, name)); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + expect(scanned.omitted).toContainEqual( + expect.objectContaining({ subject: "source maps", count: 1 }), + ); + } + }, + ); + + it("counts a script link in an ASAR as skipped", async () => { + const root = await createTestTempDirectory("rea-release-asar-script-link-"); + const archive = join(root, "app.asar"); + const payload = Buffer.from(EMBEDDED_PAYLOAD); + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes( + JSON.stringify({ + files: { + "app.js": { link: "payload" }, + payload: { size: payload.length, offset: "0" }, + }, + }), + ), + payload, + ]), + ); + expect((await inspect(archive)).source_maps).toMatchObject({ + embedded: 0, + skipped: 1, + }); + }); + + it.skipIf(process.platform === "win32")( + "counts a script link once when a page or script also names it", + async () => { + for (const [name, page] of [ + ["a-link.svg", ''], + ["z-link.svg", ''], + ] as const) { + const root = await createTestTempDirectory("rea-release-loaded-link-"); + await writeFile(join(root, "payload"), "not read\n"); + await writeFile(join(root, "m-index.html"), page); + await symlink("payload", join(root, name)); + expect((await inspect(root)).source_maps.skipped).toBe(1); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "leaves a link that names no map out of the map counts", + async () => { + const root = await createTestTempDirectory("rea-release-other-link-"); + await writeFile(join(root, "payload"), "not read\n"); + await symlink("payload", join(root, "current")); + expect((await inspect(root)).source_maps.skipped).toBe(0); + }, + ); +}); + +describe("ReleaseExposureReader packaged map identity", () => { + it("keeps a malformed sibling map the selected script declares unresolved", async () => { + const root = await createTestTempDirectory("rea-release-sibling-map-"); + await writeFile( + join(root, "app.js"), + "const value = 1;\n//# sourceMappingURL=assets.map\n", + ); + await writeFile(join(root, "assets.map"), "not a map\n"); + const scanned = await inspect(join(root, "app.js")); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + expect(scanned.source_maps).toMatchObject({ embedded: 0, skipped: 1 }); + }); + + it("keeps a named companion that a later script declares as its map", async () => { + const root = await createTestTempDirectory("rea-release-named-companion-"); + await writeFile(join(root, "symbols.pdb"), "debug companion"); + await writeFile( + join(root, "z.js"), + "const value = 1;\n//# sourceMappingURL=symbols.pdb\n", + ); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([ + expect.stringMatching(/symbols\.pdb$/u), + ]); + }); + + it.each([ + ["app.js", { embedded: 0, unparsed: 1, skipped: 0 }], + ["z.js", { embedded: 0, unparsed: 0, skipped: 1 }], + ])( + "keeps a packaged malformed assets.map that %s declares unresolved", + async (script, expected) => { + const root = await createTestTempDirectory("rea-release-packaged-map-"); + const archive = join(root, "app.asar"); + await writeFile( + archive, + asarArchive([ + { + name: script, + bytes: Buffer.from( + "const value = 1;\n//# sourceMappingURL=assets.map\n", + ), + }, + { name: "assets.map", bytes: Buffer.from("not a map\n") }, + ]), + ); + const scanned = await inspect(archive); + expect(scanned.source_maps).toMatchObject(expected); + expect(scanned.debug_companions.paths).toEqual([]); + }, + ); +}); + +it.for(["a.js", "z.js"])( + "keeps a distinct normalized linker-map occurrence when %s declares the other", + async (script, context) => { + const root = await createTestTempDirectory("rea-map-identities-"); + const first = "café.map"; + const second = "cafe\u0301.map"; + await writeFile(join(root, first), "linker map\n"); + await writeFile(join(root, second), "linker map\n"); + if ((await readdir(root)).length !== 2) { + context.skip(); + return; + } + await writeFile(join(root, script), `//# sourceMappingURL=${first}\n`); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([join(root, second)]); + }, +); + +it("keeps a distinct normalized linker map beside a page's script target", async (context) => { + const root = await createTestTempDirectory("rea-script-map-identities-"); + const first = "café.map"; + const second = "cafe\u0301.map"; + await writeFile(join(root, first), "const value = 1;\n"); + await writeFile(join(root, second), "linker map\n"); + if ((await readdir(root)).length !== 2) { + context.skip(); + return; + } + await writeFile(join(root, "a.html"), ``); + const scanned = await inspect(root); + expect(scanned.debug_companions.paths).toEqual([join(root, second)]); +}); + +it("keeps a distinct normalized sibling linker map beside a declared one", async (context) => { + const root = await createTestTempDirectory("rea-sibling-map-identities-"); + const first = "café.map"; + const second = "cafe\u0301.map"; + await writeFile(join(root, first), "linker map\n"); + await writeFile(join(root, second), "linker map\n"); + if ((await readdir(root)).length !== 2) { + context.skip(); + return; + } + const script = join(root, "app.js"); + await writeFile(script, `//# sourceMappingURL=${first}\n`); + const scanned = await inspect(script); + expect(scanned.debug_companions.paths).toEqual([join(root, second)]); +}); + +describe("ReleaseExposureReader expanded package member identity", () => { + // Split so the test file does not end with a map directive of its own. + const inlineMapScript = `//# ${"sourceMappingURL"}=data:application/json;base64,${Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ).toString("base64")}\n`; + + it("matches an on-disk page's script target inside an expanded ASAR", async () => { + const root = await createTestTempDirectory("rea-expanded-script-target-"); + await writeFile( + join(root, "a.html"), + '', + ); + await writeFile( + join(root, "app.asar"), + asarArchive([{ name: "boot.map", bytes: Buffer.from(inlineMapScript) }]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + expect(scanned.debug_companions).toEqual({ paths: [], skipped: 0 }); + }); + + it("matches an absolute file: map declaration inside an expanded ASAR", async () => { + const root = await createTestTempDirectory("rea-expanded-map-target-"); + const target = pathToFileURL(join(root, "app.asar", "app.js.data")); + await writeFile( + join(root, "a.js"), + `//# ${"sourceMappingURL"}=${target.href}\n`, + ); + await writeFile( + join(root, "app.asar"), + asarArchive([ + { + name: "app.js.data", + bytes: Buffer.from( + JSON.stringify({ + version: 3, + sources: ["src/app.ts"], + sourcesContent: ["export {};"], + mappings: "", + }), + ), + }, + ]), + ); + const scanned = await inspect(root); + expect(scanned.source_maps).toMatchObject({ embedded: 1, skipped: 0 }); + expect(sourceMapSourcesStatus(scanned)).toBe("exposed"); + }); +}); + +it.each([ + ["duplicate", "a.html"], + ["duplicate", "z.html"], + ["unicode", "a.html"], + ["unicode", "z.html"], +] as const)( + "keeps script completion separate for %s source occurrences with page at %s", + async (scenario, pageName) => { + const root = await createTestTempDirectory("rea-script-completion-"); + const first = scenario === "duplicate" ? "boot.map" : "cafe\u0301.map"; + const second = scenario === "duplicate" ? "boot.map" : "café.map"; + const inline = `const value = 1;\n//# ${"sourceMappingURL"}=data:application/json;base64,${embeddedSourceMap().toString("base64")}\n`; + const path = join(root, "application"); + const members = [ + { + name: first, + typeCode: "x", + compress: false, + payload: Buffer.from("#!/usr/bin/env node\nconsole.log('launcher');\n"), + }, + { + name: second, + typeCode: "x", + compress: false, + payload: Buffer.from(inline), + }, + ]; + members.push({ + name: pageName, + typeCode: "x", + compress: false, + payload: Buffer.from(``), + }); + await writeFile( + path, + cArchiveExecutable({ + layout: "2.1", + pythonVersion: 310, + members, + }), + ); + const scanned = await inspect(path); + expect(sourceMapSourcesStatus(scanned)).toBe( + pageName === "z.html" ? "unknown" : "exposed", + ); + expect(scanned.debug_companions.paths).toEqual([]); + }, +); diff --git a/src/artifacts/ReleaseExposureReader.ts b/src/artifacts/ReleaseExposureReader.ts new file mode 100644 index 0000000000..aef93cc944 --- /dev/null +++ b/src/artifacts/ReleaseExposureReader.ts @@ -0,0 +1,3677 @@ +import { isUtf8 } from "node:buffer"; +import { createHash } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { lstat, mkdtemp, opendir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { + ReleaseExposurePort, + ReleaseExposureScanVisitor, + ReleaseExposureTextVisitor, +} from "../application/releaseExposure/ReleaseExposurePort.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, + type ArtifactReader, +} from "./ArtifactReader.js"; +import { + ArtifactResourceScope, + type ArtifactCleanupAttempt, + type ArtifactResourceOwner, +} from "./ArtifactResourceScope.js"; +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, +} from "./ArtifactFileOperation.js"; +import { decodeBinaryPlistText } from "./apple/BinaryPlistMaterializer.js"; +import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js"; +import { + asarHeaderAdmissionRecovery, + createAsarHeaderSignatureProbe, +} from "./AsarHeader.js"; +import { + DEFAULT_ASAR_HEADER_ADMISSION, + type AsarHeaderAdmission, +} from "../config/asarHeaderAdmission.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; +import { ArtifactTraversalObservation } from "./ArtifactTraversalObservation.js"; +import { classifyEmbeddedExecutablePackage } from "./inventory/classify.js"; +import { inventoryArtifactInScope } from "./inventory/ArtifactInventory.js"; +import { + hashStableRootArtifactHandle, + openRootArtifact, + releaseRootArtifact, +} from "./inventory/hashStableRootArtifact.js"; +import { createArtifactReader } from "./createArtifactReader.js"; +import { readStableArtifact } from "./readStableArtifact.js"; +import { + projectArtifactCloseFailure, + projectStableArtifactFailure, +} from "./StableArtifactFailure.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import type { + ArtifactInventoryResult, + ArtifactOccurrence, +} from "../domain/artifactGraph.js"; +import { + classifyFatContainerEvidence, + readFatContainerHeader, + readMachoHeaderPrefixFacts, +} from "../domain/apple/machoContainer.js"; +import { inspectPeCodeViewFromBuffer } from "../domain/binaryTarget.js"; +import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js"; +import { logicalPathNfc } from "../domain/artifactIdentity.js"; +import type { ArtifactAdmissionLimit } from "../domain/artifactOperationError.js"; +import { observeMzWindowsHeader } from "../domain/dosMz.js"; +import { evidenceSubjectFormatSchema } from "../domain/evidenceSubject.js"; +import { byteOrderMarkEncoding } from "../domain/textByteOrderMark.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { RELEASE_EXPOSURE_PROVIDER } from "../domain/investigationProviders.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + PACKAGED_NATIVE_IMAGES, + MAX_RELEASE_EXPOSURE_FILE_BYTES, + MAX_RELEASE_EXPOSURE_INVENTORY_BYTES, + MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES, + MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES, + MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES, + MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS, + MAX_RELEASE_EXPOSURE_SAMPLES, + MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES, + MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES, + MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES, + MAX_RELEASE_EXPOSURE_SOURCE_MAPS, + MAX_RELEASE_EXPOSURE_TEXT_BYTES, + MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, + type ReleaseExposureScan, +} from "../domain/releaseExposure.js"; +import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; +import { appendCallerPath } from "../filesystem/callerPath.js"; +import { readFileHandleRange } from "../filesystem/readFileHandleRange.js"; +import { + isSelectedRegularFileError, + RegularFileChangedError, + sameRegularFileState, + type StableRegularFileDescriptor, +} from "../filesystem/RegularFile.js"; +import { sourceMapJsonText } from "../domain/sourceMapEnvelope.js"; +import { + inspectSourceMap, + inspectSourceMapSourceContent, +} from "../javascript/sourceMaps/SourceMapFormat.js"; +import { htmlArtifactReferences } from "../domain/javascript/htmlArtifactReferences.js"; +import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; +import { addSourceMapDirectives } from "../domain/javascript/javascriptStaticAnalysisCalls.js"; +import { createJavaScriptAnalysisAccumulator } from "../domain/javascript/javascriptStaticAnalysisState.js"; + +const OPERATION = "audit_release_exposure"; +type PackageFormat = Extract< + ArtifactOccurrence["artifact_format"], + | "asar" + | "bun-standalone" + | "deno-standalone" + | "dotnet-bundle" + | "node-sea" + | "pyinstaller" +>; +const PACKAGE_FORMATS = [ + "asar", + "bun-standalone", + "deno-standalone", + "dotnet-bundle", + "node-sea", + "pyinstaller", +] as const satisfies readonly PackageFormat[]; +const EXTRACTABLE = new Set(PACKAGE_FORMATS); +const asPackageFormat = (format: string): PackageFormat | null => + EXTRACTABLE.has(format) ? (format as PackageFormat) : null; +const NATIVE = new Set(["elf", "mach-o", "mach-o-universal", "pe"]); +const TEXT_EXTENSION = + /\.(?:cjs|css|cts|env|html|htm|ini|js|json|jsx|map|md|mjs|mts|pem|plist|toml|ts|tsx|txt|xml|yaml|yml)$/iu; +const SIBLING_LIST_LIMIT = 4096; +const CONTAINER_LIST_LIMIT = 4096; +const OPEN_CONTAINER_LIMIT = 8; +const EMBEDDED_PROBE_LIMIT = 8; +const PDB_READ_LIMIT = 4; +const LIMITATION_CAP = 8; +const SCAN_INTERRUPTED = + "The member scan stopped on unfinished cleanup. Members it did not reach are unknown."; +const SCAN_CANCELLED = + "The member scan was cancelled. Members it did not reach are unknown."; +/** Decoded representation bound for one binary plist text member. */ +const RELEASE_EXPOSURE_PLIST_DECODE_BYTES = 16 * 1024 * 1024; +/** Bytes of a member that decide how its NULs are read. */ +const MEMBER_ENCODING_HEADER_BYTES = 512; +/** One header byte in this many being NUL is too many for UTF-8 text. */ +const WIDE_TEXT_NUL_DIVISOR = 8; +const PE_MEMBER_NAME = /\.(?:dll|exe|sys)$/iu; +const JAVASCRIPT_MEMBER_NAME = /\.(?:[cm]?[jt]s|[jt]sx)$/iu; +/** Markup that `htmlArtifactReferences` reads for scripts. SVG can carry them too. */ +const SCRIPT_PAGE_NAME = /\.(?:html?|svg)$/iu; + +type NativeFormat = "elf" | "mach-o" | "mach-o-universal" | "pe"; +type InventoryOccurrence = ArtifactInventoryResult["occurrences"][number]; + +/** One read attempt. Allowance and read failure are different facts. */ +type MemberRead = + | { readonly kind: "bytes"; readonly bytes: Buffer } + | { readonly kind: "over_allowance"; readonly prefix: Buffer | null } + | { readonly kind: "unread" }; +type ReadMember = (budget: number) => Promise; + +/** Interpretation is separate from the member's source path. */ +interface SourceMapReference { + readonly location: string; + readonly kind: "javascript-source-map" | "possible-linker-map"; +} + +const namedMapReference = ( + logical: string, + location: string, +): SourceMapReference => ({ + location, + kind: isJavaScriptSourceMapName(logical) + ? "javascript-source-map" + : "possible-linker-map", +}); + +interface OpenContainer { + readonly path: string; + readonly reader: ArtifactReader; + entries: readonly ArtifactEntry[] | null; + /** + * Entries by container-relative logical path, in listing order. Inventory + * numbers same-path occurrences in that order, so an occurrence's duplicate + * ordinal selects its entry no matter which read path reaches it first. + */ + readonly byPath: Map; +} + +/** Read-only inventory and bounded member scan for one shipped artifact. */ +export class ReleaseExposureReader implements ReleaseExposurePort { + readonly identity = RELEASE_EXPOSURE_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + /** + * @param asarHeaderAdmission The shared ASAR header/index admission the + * composition resolved from REA_ASAR_MAX_HEADER_BYTES and the runtime + * asar.max_header_bytes setting; read on each use, so a runtime change + * applies to the next audit. + */ + constructor( + private readonly asarHeaderAdmission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION, + ) {} + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + RELEASE_EXPOSURE_PROVIDER.id, + OPERATION, + ); + } + } + + async inspect( + input: { readonly path: string; readonly max_file_bytes?: number }, + options: ExecutionOptions | undefined, + visitText: ReleaseExposureTextVisitor, + visitInterruptedScan?: ReleaseExposureScanVisitor, + ): Promise> { + const maxFileBytes = + input.max_file_bytes ?? MAX_RELEASE_EXPOSURE_FILE_BYTES; + const context: ScanContext = { + signal: options?.signal, + resources: this.#resources, + maxFileBytes, + asarHeaderAdmission: this.asarHeaderAdmission, + visitText, + visitInterruptedScan, + siblingCleanup: [], + }; + try { + return await this.#resources.run(async () => { + if (options?.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }); + const info = await lstat(input.path); + if (info.isSymbolicLink()) + throw new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact is a symlink: ${input.path}.`, + }, + ]); + if (info.isDirectory()) + return ok(await scanDirectory(input.path, context)); + const snapshot = await readStableArtifact( + input.path, + maxFileBytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + await scanFile(input.path, snapshot.bytes, snapshot.sha256, context), + ); + }); + } catch (cause) { + return err(releaseExposureFailure(cause, input.path, maxFileBytes)); + } + } +} + +const scanDirectory = async ( + path: string, + context: ScanContext, +): Promise => { + const inventory = await inventoryArtifactInScope(path, { + acquisitionLimits: { + maximumEntries: MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES, + maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES, + }, + // Signing an unpacked native module after packaging is routine. Record the + // contradiction instead of refusing the whole audit. + integrity: "record-and-continue", + resourceScope: context.resources, + signal: context.signal, + selectedPath: path, + asarHeaderAdmission: context.asarHeaderAdmission, + }); + return consumeInventory({ + ...context, + path, + directory: true, + sha256: null, + subjectSha256: inventory.manifest.root_sha256, + snapshot: null, + inventory, + }); +}; + +const scanFile = async ( + path: string, + bytes: Buffer, + sha256: string, + context: ScanContext, +): Promise => { + let inventory: ArtifactInventoryResult | undefined; + const limitations: string[] = []; + try { + inventory = await inventoryArtifactInScope(path, { + acquisitionLimits: { + maximumEntries: MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES, + maximumBytes: MAX_RELEASE_EXPOSURE_INVENTORY_BYTES, + }, + integrity: "record-and-continue", + resourceScope: context.resources, + signal: context.signal, + selectedPath: path, + expectedRootSha256: sha256, + asarHeaderAdmission: context.asarHeaderAdmission, + }); + } catch (cause) { + rethrowReadFailure(cause); + // A named admission owns its recovery; keep it typed as the directory scan does. + if (cause instanceof ArtifactReaderFailure && cause.admission !== undefined) + throw cause; + limitations.push( + "Artifact inventory did not complete. Member coverage is unknown.", + ); + } + const scanned = await consumeInventory({ + ...context, + path, + directory: false, + sha256, + subjectSha256: sha256, + snapshot: bytes, + inventory, + }); + return { + ...scanned, + coverage: inventory === undefined ? "unknown" : scanned.coverage, + limitations: [...limitations, ...scanned.limitations].slice( + 0, + LIMITATION_CAP, + ), + }; +}; + +interface ScanContext { + readonly signal: AbortSignal | undefined; + readonly resources: ArtifactResourceScope; + readonly maxFileBytes: number; + readonly asarHeaderAdmission: AsarHeaderAdmission; + readonly visitText: ReleaseExposureTextVisitor; + readonly visitInterruptedScan: ReleaseExposureScanVisitor | undefined; + readonly siblingCleanup: Extract< + ArtifactCleanupAttempt, + { kind: "failed" } + >[]; +} + +interface ConsumeInput extends ScanContext { + readonly path: string; + readonly directory: boolean; + readonly sha256: string | null; + readonly subjectSha256: string; + readonly snapshot: Buffer | null; + readonly inventory: ArtifactInventoryResult | undefined; +} + +const consumeInventory = async ( + input: ConsumeInput, +): Promise => { + const containers = new Map(); + const progress: ScanProgress = {}; + let result: ReleaseExposureScan | undefined; + let failure: unknown; + let failed = false; + try { + result = await scanOccurrences(input, containers, progress); + } catch (cause) { + failure = cause; + failed = true; + } + const [firstCleanup, ...remainingCleanup] = [ + ...input.siblingCleanup, + ...(await releaseContainers(containers, input.resources)), + ]; + // A cancelled scan keeps what it examined, as one stopped on cleanup does; + // the caller still receives the cancellation. + const cancelled = failed && wasCancelled(failure); + if ( + firstCleanup !== undefined || + (failed && leftCleanupUnfinished(failure)) || + cancelled + ) + reportInterruptedScan( + input, + result ?? progress.assemble?.(), + cancelled && firstCleanup === undefined && !leftCleanupUnfinished(failure) + ? SCAN_CANCELLED + : SCAN_INTERRUPTED, + ); + if (firstCleanup !== undefined) { + const primary = failed + ? releaseExposureFailure(failure, input.path, input.maxFileBytes) + : new ProviderAdapterError(RELEASE_EXPOSURE_PROVIDER.id, OPERATION, { + cause: firstCleanup.cause, + diagnostics: + result === undefined + ? undefined + : { scan: jsonValueSchema.parse(result) }, + }); + throw projectStableArtifactFailure( + input.resources.cleanupFailure({ kind: "completed" }, [ + firstCleanup, + ...remainingCleanup, + ]), + primary, + input.path, + RELEASE_EXPOSURE_PROVIDER.id, + OPERATION, + ); + } + if (failed) throw failure; + if (result === undefined) + throw new Error("Release exposure scan produced no result."); + return result; +}; + +/** What a failed scan can still assemble once it has somewhere to record facts. */ +interface ScanProgress { + assemble?: () => ReleaseExposureScan; +} + +const leftCleanupUnfinished = (cause: unknown): boolean => { + const failure = asReadFailure(cause); + if (failure instanceof AnalysisError) return failure.cleanupIncomplete; + return ( + failure instanceof ArtifactReaderFailure && failure.cleanup !== undefined + ); +}; + +const wasCancelled = (cause: unknown): boolean => { + const failure = asReadFailure(cause); + return ( + failure instanceof AnalysisCancelledError || + (failure instanceof ArtifactReaderFailure && failure.reason === "cancelled") + ); +}; + +/** Completed findings survive the failure; what was not reached stays unknown. */ +const reportInterruptedScan = ( + input: ConsumeInput, + scan: ReleaseExposureScan | undefined, + reason: string, +): void => { + if (scan === undefined) return; + input.visitInterruptedScan?.({ + ...scan, + coverage: "unknown", + limitations: [reason, ...scan.limitations].slice(0, LIMITATION_CAP), + }); +}; + +/** Hand each package reader to the resource scope so a failed close keeps its path and can be retried. */ +const releaseContainers = async ( + containers: Map, + resources: ArtifactResourceScope, +): Promise[]> => { + const owned = [...containers.values()].reverse(); + containers.clear(); + const failures: Extract[] = []; + for (const container of owned) { + const outcome = await resources.release({ + kind: "reader", + resource: container.path, + reader: container.reader, + }); + if (outcome.kind === "failed") failures.push(outcome); + } + return failures; +}; + +const scanOccurrences = async ( + input: ConsumeInput, + containers: Map, + progress: ScanProgress, +): Promise => { + const occurrences = input.inventory?.occurrences ?? []; + const byId = new Map( + occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]), + ); + // Inventory numbers same-path occurrences in listing order; keep that + // number as the occurrence's identity within its container. + const ordinals = new Map(); + const seenPaths = new Map(); + for (const occurrence of occurrences) { + const ordinal = seenPaths.get(occurrence.logical_path) ?? 0; + seenPaths.set(occurrence.logical_path, ordinal + 1); + ordinals.set(occurrence.occurrence_id, ordinal); + } + const nodeFormat = new Map( + (input.inventory?.nodes ?? []).map((node) => [ + node.artifact_id, + node.format, + ]), + ); + const nodeSha = new Map( + (input.inventory?.nodes ?? []).map((node) => [ + node.artifact_id, + node.sha256, + ]), + ); + const parentsWithChildren = new Set( + occurrences.flatMap((occurrence) => + occurrence.parent_occurrence_id === null + ? [] + : [occurrence.parent_occurrence_id], + ), + ); + const formatOf = (occurrence: InventoryOccurrence): string => { + const fromNode = + occurrence.artifact_id === null + ? undefined + : nodeFormat.get(occurrence.artifact_id); + if (fromNode !== undefined && EXTRACTABLE.has(fromNode)) return fromNode; + return occurrence.artifact_format; + }; + const rootFormat = + input.inventory?.manifest.root_format ?? + (input.snapshot === null + ? "directory" + : sniffNativeFormat(input.snapshot)) ?? + "file"; + const state = createState( + input.visitText, + input.asarHeaderAdmission, + input.path, + ); + progress.assemble = () => assembleScan(input, state, rootFormat); + if (EXTRACTABLE.has(rootFormat)) state.formats.add(rootFormat); + if (input.snapshot !== null) + await examineSelectedFile(state, input, rootFormat, input.snapshot); + for (const occurrence of occurrences) { + if (input.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: input.signal.reason, + }); + if ( + !input.directory && + occurrence.parent_occurrence_id === null && + input.snapshot !== null + ) + continue; + const packaged = packageAncestor(occurrence, byId, formatOf); + const format = formatOf(occurrence); + const logical = occurrence.logical_path; + const location = diskPath(input, occurrence, byId, formatOf) ?? logical; + if (hasCompanionName(logical)) state.companionPaths.add(location); + noteUnfollowedMapLink(state, occurrence.entry_kind, logical, location); + if (occurrence.entry_kind !== "file") continue; + const nativeFormat = nativeFormatOf(occurrence, nodeFormat); + const onDisk = + nativeFormat !== null && + !hasExtractableAncestor(occurrence, byId, formatOf) && + isAbsolute(location); + if (onDisk && nativeFormat !== null) { + if (!state.native.has(location)) { + state.native.set(location, nativeFormat); + const sha256 = shaOf(occurrence, nodeSha); + if (sha256 !== undefined) state.nativeDigests.set(location, sha256); + if (nativeFormat === "pe") + await examinePdb(location, sha256, state, input); + await scanEmbeddedPackage( + input, + occurrence, + location, + nativeFormat, + containers, + state, + ); + } + continue; + } + const packageKind = asPackageFormat(format); + if ( + packageKind !== null && + !hasExtractableAncestor(occurrence, byId, formatOf) + ) { + state.formats.add(packageKind); + if ( + !parentsWithChildren.has(occurrence.occurrence_id) && + isAbsolute(location) + ) + await scanUnexpandedContainer( + input, + occurrence, + location, + packageKind, + containers, + state, + ); + continue; + } + // A nested package that inventory recognized but did not expand was + // never assessed, so its members leave coverage unknown. The nested + // package itself is still an unencrypted member of its parent that + // inventory already read, so it counts as a readable payload. + if ( + packageKind !== null && + !parentsWithChildren.has(occurrence.occurrence_id) + ) { + noteNestedPackage(state, packageKind, location); + if (!occurrence.encrypted) { + state.readable += 1; + pushSample(state.memberSamples, location); + } + continue; + } + await examineMember(state, input, { + logical, + format: nativeFormat ?? format, + kind: + ownerStatedKind(occurrence.format_metadata) ?? occurrence.artifact_kind, + kindStated: ownerStatedKind(occurrence.format_metadata) !== null, + encrypted: occurrence.encrypted, + declaredSize: occurrence.declared_size, + location, + packageFormat: + packaged === undefined ? null : asPackageFormat(formatOf(packaged)), + read: (budget) => + readOccurrence( + input, + occurrence, + ordinals, + containers, + byId, + formatOf, + nodeSha, + state, + budget, + ), + }); + } + if (!input.directory) await scanSiblings(input, state); + noteUnreachedTargets(state); + return assembleScan(input, state, rootFormat); +}; + +const assembleScan = ( + input: ConsumeInput, + state: ScanState, + rootFormat: string, +): ReleaseExposureScan => { + const kind = targetKind(input.path, rootFormat, input.directory); + const nativeImages = [...state.native.entries()].map(([path, format]) => { + const sha256 = state.nativeDigests.get(path); + return { path, format, ...(sha256 === undefined ? {} : { sha256 }) }; + }); + return { + target: { + selected_path: input.path, + kind, + format: rootFormat, + sha256: input.sha256, + }, + subject: { + path: input.path, + sha256: input.subjectSha256, + format: evidenceSubjectFormatSchema.safeParse(rootFormat).success + ? rootFormat + : "file", + }, + native_images: nativeImages, + fuse_candidates: [ + ...(kind === "app" + ? [{ path: input.path, role: "app-bundle" as const }] + : []), + ...nativeImages.map((image) => ({ + path: image.path, + role: "native-image" as const, + })), + ], + source_maps: { + embedded: state.embedded, + without_sources: state.withoutSources, + unparsed: state.unparsedMaps, + skipped: state.skippedMaps, + samples: [...state.mapSamples], + embedded_paths: [...state.embeddedPaths], + plain_paths: [...state.plainPaths], + }, + extractable: { + formats: [...state.formats], + readable_members: state.readable, + unreadable_members: state.unreadable, + samples: [...state.memberSamples], + }, + debug_companions: { + paths: [ + ...state.companionPaths, + ...[...state.linkerMapGuesses.values()].flatMap((paths) => [...paths]), + ], + skipped: state.skippedCompanions, + }, + pdb: { + examined: state.pdbExamined, + paths: [...state.pdbPaths], + image_paths: [...state.pdbImagePaths], + failed: state.pdbFailed, + }, + text_members: { + scanned: state.scannedText, + skipped: state.skippedText, + }, + coverage: + input.inventory === undefined || state.coverageUnknown + ? "unknown" + : input.inventory.coverage.status === "complete" + ? "complete" + : "unknown", + omitted: [ + ...(state.skippedMaps > 0 + ? [ + { + subject: "source maps", + count: state.skippedMaps, + reason: + "A source map exceeded the map count or byte allowance, could not be read, was a link that is not followed, was past the package entry cap or the HTML inline script limit, was declared but not found, or sat in a script an HTML file loads that was not examined as one; inline CSS source-map directives are not inspected.", + }, + ] + : []), + ...(state.skippedText > 0 + ? [ + { + subject: "text members", + count: state.skippedText, + reason: + "A text member exceeded the text byte allowance, could not be read, or was past the package entry cap.", + }, + ] + : []), + ...(state.skippedCompanions > 0 + ? [ + { + subject: "debug companions", + count: state.skippedCompanions, + reason: + "A .map file without a JavaScript name could not be classified, or the listing beside the selected file was incomplete.", + }, + ] + : []), + ...(state.packagedNative > 0 + ? [ + { + subject: PACKAGED_NATIVE_IMAGES, + count: state.packagedNative, + reason: + "Go, Rust, and Electron fuse readers run on files on disk, not on images stored inside a package.", + }, + ] + : []), + ...(state.unprobedImages > 0 + ? [ + { + subject: "embedded package probes", + count: state.unprobedImages, + reason: `Only ${String(EMBEDDED_PROBE_LIMIT)} native images on disk, and packaged images up to the file byte allowance in total, are probed for an embedded package.`, + }, + ] + : []), + ], + limitations: [ + ...(input.inventory?.limitations ?? []), + ...state.limitations, + ].slice(0, LIMITATION_CAP), + }; +}; + +interface ScanState { + embedded: number; + withoutSources: number; + unparsedMaps: number; + skippedMaps: number; + mapSamples: string[]; + embeddedPaths: string[]; + plainPaths: string[]; + formats: Set; + readable: number; + unreadable: number; + memberSamples: string[]; + /** Companions identified by name. A later declaration does not change them. */ + companionPaths: Set; + /** + * Maps read as non-JSON, by lookup identity, each with its reported spellings, + * which symlinks can make differ. A later map or script declaration + * retracts the guess. + */ + linkerMapGuesses: Map>; + skippedCompanions: number; + pdbExamined: number; + pdbPaths: string[]; + pdbImagePaths: string[]; + pdbFailed: number; + coverageUnknown: boolean; + pdbAttempts: number; + packagedPeReads: number; + packagedNative: number; + scannedText: number; + skippedText: number; + textBytes: number; + /** Bytes member reads produced, kept even when the read then failed. */ + memberBytesRead: number; + embeddedProbes: number; + /** Member bytes spent probing packaged images for an embedded package. */ + packagedProbeBytes: number; + unprobedImages: number; + native: Map; + nativeDigests: Map; + limitations: string[]; + mapsRead: number; + /** + * Local targets of source-map declarations, whatever their file name, by + * lookup identity, each with the resolved spellings declarations wrote. + */ + mapTargets: Map>; + /** + * Map targets a member or sibling reached through a declared spelling. Any + * other spelling in the same lookup bucket is a distinct occurrence. + */ + claimedMapTargets: Set; + /** Completion per source occurrence, indexed by exact resolved spelling. */ + examinedMembers: Map>>; + /** + * Local script elements' targets whose names do not say JavaScript, by + * lookup identity, each with the resolved spellings pages wrote. + */ + scriptTargets: Map>; + /** Bytes of resolved path offered to `scriptTargets`, kept or refused. */ + scriptTargetBytes: number; + mapTargetBytes: number; + /** + * A declared map or script target was refused by its allowance. A later + * `.map` may be that target, so it can no longer settle as a linker map. + */ + mapTargetsDiscarded: boolean; + visitText: ReleaseExposureTextVisitor; + readonly asarHeaderAdmission: AsarHeaderAdmission; + /** + * The selected path. An expanded package member reports its location + * relative to it, so lookups resolve that location here rather than + * against the process working directory. + */ + readonly anchor: string; +} + +const createState = ( + visitText: ReleaseExposureTextVisitor, + asarHeaderAdmission: AsarHeaderAdmission, + anchor: string, +): ScanState => ({ + asarHeaderAdmission, + anchor, + embedded: 0, + withoutSources: 0, + unparsedMaps: 0, + skippedMaps: 0, + mapSamples: [], + embeddedPaths: [], + plainPaths: [], + formats: new Set(), + readable: 0, + unreadable: 0, + memberSamples: [], + companionPaths: new Set(), + linkerMapGuesses: new Map(), + skippedCompanions: 0, + pdbExamined: 0, + pdbPaths: [], + pdbImagePaths: [], + pdbFailed: 0, + coverageUnknown: false, + pdbAttempts: 0, + packagedPeReads: 0, + packagedNative: 0, + scannedText: 0, + skippedText: 0, + textBytes: 0, + memberBytesRead: 0, + embeddedProbes: 0, + packagedProbeBytes: 0, + unprobedImages: 0, + native: new Map(), + nativeDigests: new Map(), + limitations: [], + mapsRead: 0, + mapTargets: new Map(), + claimedMapTargets: new Set(), + examinedMembers: new Map(), + scriptTargets: new Map(), + scriptTargetBytes: 0, + mapTargetBytes: 0, + mapTargetsDiscarded: false, + visitText, +}); + +interface MemberCompletion { + map: boolean; + script: boolean; +} + +interface MemberView { + readonly completion: MemberCompletion; + readonly logical: string; + readonly format: string; + readonly kind: string; + /** + * The package owner established `kind` from its own records. Inventory's + * path roles, such as `resource` for `payload.json`, are only name hints. + */ + readonly kindStated: boolean; + readonly encrypted: boolean; + /** Full member size its owner declared, when one did. */ + readonly declaredSize: number | null; + /** Reported path. A package member names its host file. */ + readonly location: string; + readonly packageFormat: PackageFormat | null; + readonly read: ReadMember; +} + +type MemberRole = Pick; + +const isJavaScriptMember = (member: MemberRole): boolean => + member.kind === "javascript" || JAVASCRIPT_MEMBER_NAME.test(member.logical); + +const isCssMember = (member: MemberRole): boolean => + member.logical.toLowerCase().endsWith(".css"); + +const isScriptPageMember = (member: MemberRole): boolean => + SCRIPT_PAGE_NAME.test(member.logical); + +/** A member whose scripts can declare a map the directive parser reads. */ +const holdsScripts = (member: MemberRole): boolean => + isJavaScriptMember(member) || isScriptPageMember(member); + +/** A member that can declare a map, whether or not the parser reads it. */ +const canDeclareMap = (member: MemberRole): boolean => + holdsScripts(member) || isCssMember(member); + +/** Bun loaders whose module contents a compiled executable ships as JavaScript. */ +const BUN_SCRIPT_LOADERS = new Set(["js", "jsx", "ts", "tsx"]); +const DENO_SCRIPT_MEDIA_TYPES = new Set([ + "JavaScript", + "Jsx", + "Mjs", + "Cjs", + "TypeScript", + "Mts", + "Cts", + "Tsx", +]); + +/** + * The role a package reader states for its entry, or null when it states none. + * A Bun sourcemap region is named `.bunmap`, a Bun module such as + * `$bunfs/root/release` need not name a script, and a SEA entry's code path + * need not either, so their names alone do not say what they are. + */ +const ownerStatedKind = ( + metadata: ArtifactOccurrence["format_metadata"], +): string | null => { + if (metadata?.kind === "bun-standalone-entry") { + if (metadata.region === "sourcemap") return "source-map"; + if ( + metadata.region === "contents" && + BUN_SCRIPT_LOADERS.has(metadata.loader_name ?? "") + ) + return "javascript"; + return null; + } + if (metadata?.kind === "deno-standalone-entry") { + if (metadata.role === "remote-module") { + if (metadata.media_type_name === "SourceMap") return "source-map"; + if (DENO_SCRIPT_MEDIA_TYPES.has(metadata.media_type_name ?? "")) + return "javascript"; + } + if ( + metadata.role === "vfs-source-map" || + metadata.role === "remote-source-map" + ) + return "source-map"; + if ( + metadata.role === "vfs-transpiled" || + metadata.role === "remote-transpiled" + ) + return "javascript"; + } + if (metadata?.kind === "node-sea-entry" && metadata.role === "main-script") + return "javascript"; + return null; +}; + +const asNativeFormat = ( + format: string | null | undefined, +): NativeFormat | null => + format !== undefined && format !== null && NATIVE.has(format) + ? (format as NativeFormat) + : null; + +const nativeFormatOf = ( + occurrence: InventoryOccurrence, + nodeFormat: Map, +): NativeFormat | null => { + // An embedded package keeps its container format. The host node may still + // be pe or elf, and that native label must not hide the package. + if (EXTRACTABLE.has(occurrence.artifact_format)) return null; + const fromNode = + occurrence.artifact_id === null + ? undefined + : nodeFormat.get(occurrence.artifact_id); + if (fromNode !== undefined && EXTRACTABLE.has(fromNode)) return null; + return asNativeFormat(occurrence.artifact_format) ?? asNativeFormat(fromNode); +}; + +const shaOf = ( + occurrence: InventoryOccurrence, + nodeSha: Map, +): string | undefined => + occurrence.artifact_id === null + ? undefined + : nodeSha.get(occurrence.artifact_id); + +const overAllowance = (bytes: Buffer): MemberRead => ({ + kind: "over_allowance", + prefix: bytes.subarray(0, MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES), +}); + +const fitAllowance = (bytes: Buffer, budget: number): MemberRead => + bytes.length <= budget ? { kind: "bytes", bytes } : overAllowance(bytes); + +const examineSelectedFile = async ( + state: ScanState, + input: ConsumeInput, + rootFormat: string, + bytes: Buffer, +): Promise => { + const nativeFormat = asNativeFormat(rootFormat) ?? sniffNativeFormat(bytes); + if (nativeFormat !== null) { + state.native.set(input.path, nativeFormat); + if (input.sha256 !== null) + state.nativeDigests.set(input.path, input.sha256); + } + if (nativeFormat === "pe") readPdb(state, bytes, input.path); + await examineMember(state, input, { + logical: basename(input.path), + // A selected package executable keeps its native carrier here, so its + // name cannot route it as a linker map. + format: nativeFormat ?? rootFormat, + kind: "file", + kindStated: false, + encrypted: false, + declaredSize: bytes.length, + location: input.path, + packageFormat: null, + read: (budget) => Promise.resolve(fitAllowance(bytes, budget)), + }); +}; + +/** + * Examine one file member for the checks it can affect. + * A listed, unencrypted package member is readable even when a read allowance + * skips it, because the allowance belongs to this audit and not to the format. + */ +const examineMember = async ( + state: ScanState, + context: ScanContext, + listed: Omit, +): Promise => { + const completion: MemberCompletion = { map: false, script: false }; + const source = { ...listed, completion }; + recordMemberCompletion(state, listed.location, completion); + const { packageFormat } = listed; + if (packageFormat !== null) state.formats.add(packageFormat); + let readable = false; + const map = isDeclaredMap(state, source); + const member = + !map && + asNativeFormat(listed.format) === null && + isScriptTarget(state, listed.location) + ? { ...source, kind: "javascript" } + : source; + completion.map = map; + completion.script = !map && isJavaScriptMember(member); + // An owner's native format outranks anything the member's name suggests, + // for a packaged member and for a selected or listed native image alike. + const native = asNativeFormat(member.format) !== null; + if (member.encrypted) noteUnreadMember(state, member); + else if (native) { + if (packageFormat !== null) + readable = await examinePackagedBinary(state, member, context); + } else if (map) readable = await examineMapMember(state, member, context); + else if (isText(member.logical, member.kind)) + readable = await examineTextMember(state, member, context); + else if (packageFormat !== null) + readable = await examinePackagedBinary(state, member, context); + else + await examineProbedText( + state, + member, + await member.read(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES), + context, + ); + if (packageFormat === null) return; + if (!readable) { + state.unreadable += 1; + return; + } + state.readable += 1; + pushSample(state.memberSamples, member.location); +}; + +const noteUnreadMember = (state: ScanState, member: MemberView): void => { + if (canDeclareMap(member)) state.skippedMaps += 1; + if ( + member.packageFormat !== null && + !member.kindStated && + asNativeFormat(member.format) === null + ) { + if (isDeclaredMap(state, member)) + noteUnreadMap( + state, + namedMapReference(member.logical, member.location), + null, + ); + noteUnreadBinary(state, member); + return; + } + if (asNativeFormat(member.format) !== null) noteUnreadBinary(state, member); + else if (isDeclaredMap(state, member)) { + noteUnreadMap( + state, + namedMapReference(member.logical, member.location), + null, + ); + state.skippedText += 1; + } else if (isText(member.logical, member.kind)) state.skippedText += 1; + else noteUnreadBinary(state, member); +}; + +/** + * A member whose bytes were not read. A native one may carry a package that + * nothing here can clear, so member coverage stays unknown, as it does for a + * packaged image the embedded-package probe could not read. An unclassified + * one whose header was never read may be such an image too, so it leaves + * coverage unknown as well. A caller that counts the failed read itself passes + * `countUnreadable: false`, so the member is counted once. + */ +const noteUnreadBinary = ( + state: ScanState, + member: MemberView, + countUnreadable = true, +): void => { + const native = asNativeFormat(member.format); + if (native !== null) { + state.packagedNative += 1; + if (countUnreadable) noteUnexaminedPackage(state); + else state.coverageUnknown = true; + pushLimitation( + state, + `Did not probe ${member.location} for an embedded package.`, + ); + } else { + state.skippedText += 1; + state.coverageUnknown = true; + pushLimitation( + state, + `Could not read ${member.location} to rule out a native image or package.`, + ); + } + if (native === "pe" || PE_MEMBER_NAME.test(member.logical)) + notePeNotExamined(state, member.location); +}; + +const notePeNotExamined = (state: ScanState, location: string): void => { + state.pdbFailed += 1; + pushLimitation(state, `Did not examine PE debug records in ${location}.`); +}; + +const examineMapMember = async ( + state: ScanState, + member: MemberView, + context: ScanContext, +): Promise => { + // A member a script names or an owner marks as a map is a JavaScript map + // whatever it is called. Only an undeclared `.map` may be a linker map. + // Another spelling of a declared target is undeclared until the scan ends. + const undeclared = + member.logical.toLowerCase().endsWith(".map") && + !claimMapTarget(state, member.location); + const declared: SourceMapReference & Pick = { + ...(undeclared + ? namedMapReference(member.logical, member.location) + : { location: member.location, kind: "javascript-source-map" as const }), + read: member.read, + }; + const bytesReadBefore = state.memberBytesRead; + const read = await readMap(state, declared); + const header = read === null ? null : headerOfRead(read.loaded); + if (undeclared && read !== null && header !== null && hasNodeShebang(header)) + return examineShebangMap(state, member, read.loaded, bytesReadBefore); + // A shebang whose program an unresolved variable names may run Node, so the + // member may be a script and is no linker-map evidence, as for a sibling. + if ( + undeclared && + read !== null && + header !== null && + nodeShebang(header) === undefined + ) { + state.skippedMaps += 1; + state.skippedCompanions += 1; + if (read.loaded.kind === "bytes") + scanText( + state, + member, + read.loaded.bytes.length, + decodeMemberText(read.loaded.bytes), + ); + // A read the allowance stopped leaves the text unchecked. + else state.skippedText += 1; + return true; + } + const packaged = + read === null ? null : packagedFormatOfRead(state, member, read.loaded); + if (packaged === "asar") { + noteNestedPackage(state, "asar", member.location); + return true; + } + if (packaged !== null) + return examinePackagedBinary( + state, + { ...member, format: packaged }, + context, + ); + // A package member whose PE header is undecided may be native, so its `MZ` + // prefix is no evidence of a linker map. + if ( + header !== null && + member.packageFormat !== null && + !member.kindStated && + noteUndecidedPe(state, member, header) + ) { + // Bytes read in full are still text, so their credential markers count. + if (read?.loaded.kind === "bytes") + scanText( + state, + member, + read.loaded.bytes.length, + decodeMemberText(read.loaded.bytes), + ); + else state.skippedText += 1; + return true; + } + if (read !== null) settleMapRead(state, declared, read); + const loaded = read?.loaded ?? null; + if (loaded?.kind === "bytes") + scanText( + state, + member, + loaded.bytes.length, + decodeMemberText(loaded.bytes), + ); + // A package member's name is not its format, so one whose header was never + // read may be a native image or package, as on the text route. + else if ( + header === null && + member.packageFormat !== null && + !member.kindStated + ) + noteUnreadBinary(state, member); + else state.skippedText += 1; + return loaded?.kind !== "unread"; +}; + +/** + * An undeclared `.map` that starts with a Node shebang is a script, such as + * `runner.map`, so it is not a linker map. Its completed map read keeps its + * slot and its bytes are reused for the text and inline-map checks. + */ +const examineShebangMap = ( + state: ScanState, + member: MemberView, + loaded: MemberRead, + bytesReadBefore: number, +): boolean => { + member.completion.map = false; + member.completion.script = true; + const remaining = MAX_RELEASE_EXPOSURE_TEXT_BYTES - state.textBytes; + if ( + loaded.kind === "bytes" && + loaded.bytes.length <= + Math.min(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, remaining) + ) + return examineLoadedText( + state, + { ...member, kind: "javascript" }, + loaded, + bytesReadBefore, + ); + state.textBytes += Math.min( + Math.max(remaining, 0), + state.memberBytesRead - bytesReadBefore, + ); + state.skippedText += 1; + state.skippedMaps += 1; + return loaded.kind !== "unread"; +}; + +/** The first bytes a read returned, or null when it returned none. */ +const headerOfRead = (loaded: MemberRead): Buffer | null => + loaded.kind === "bytes" + ? loaded.bytes + : loaded.kind === "over_allowance" + ? loaded.prefix + : null; + +/** + * Take a slot from the map count and return the bytes this read may use. The + * read is what the count bounds, so it is charged before the file is opened: + * an oversized or unreadable map spends its slot like a parsed one. With no + * slot left, only the probe is read, which can still settle a linker map. + */ +const chargeMapRead = ( + state: ScanState, +): { hasSlot: boolean; bytes: number } => { + if (state.mapsRead >= MAX_RELEASE_EXPOSURE_SOURCE_MAPS) + return { hasSlot: false, bytes: MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES }; + state.mapsRead += 1; + return { hasSlot: true, bytes: MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES }; +}; + +interface MapRead { + readonly hasSlot: boolean; + readonly loaded: MemberRead; +} + +/** The bytes a map read may use, or null when no read is allowed. */ +const readMap = async ( + state: ScanState, + map: SourceMapReference & Pick, +): Promise => { + const { hasSlot, bytes } = chargeMapRead(state); + if (!hasSlot && map.kind === "javascript-source-map") { + state.skippedMaps += 1; + return null; + } + return { hasSlot, loaded: await map.read(bytes) }; +}; + +const settleMapRead = ( + state: ScanState, + map: SourceMapReference, + { hasSlot, loaded }: MapRead, +): void => { + if (loaded.kind === "unread") noteUnreadMap(state, map, null); + else if (loaded.kind === "over_allowance") + noteUnreadMap(state, map, loaded.prefix); + else if (hasSlot) readSourceMap(state, map, loaded.bytes); + else noteUnreadMap(state, map, loaded.bytes); +}; + +/** + * Settle a .map whose body was not parsed. A JavaScript name, JSON prefix, + * or whitespace-only prefix keeps it a possible source map. Other prefixes + * cannot parse as one, which is the same evidence that classifies a small + * linker map. + */ +const noteUnreadMap = ( + state: ScanState, + map: SourceMapReference, + prefix: Buffer | null, +): void => { + if (map.kind === "javascript-source-map") { + state.skippedMaps += 1; + return; + } + if (prefix !== null && !mayHaveSourceMapPrefix(prefix)) { + noteLinkerMapGuess(state, map.location); + return; + } + state.skippedMaps += 1; + state.skippedCompanions += 1; +}; + +/** + * A map's text in the encoding its byte order mark names, or null for one this + * reader does not decode, which leaves the map unresolved. Unmarked bytes are + * read as UTF-8, as a source map is. + */ +const mapText = (bytes: Buffer): string | null => { + const encoding = byteOrderMarkEncoding(bytes); + if (encoding === null) return bytes.toString("utf8"); + if (encoding === "utf-32") return null; + return new TextDecoder(encoding).decode(bytes); +}; + +const mayHaveSourceMapPrefix = (prefix: Buffer): boolean => { + const text = mapText(prefix); + return text === null || mayHaveSourceMapText(text); +}; + +const mayHaveSourceMapText = (decoded: string): boolean => { + const text = sourceMapJsonText(decoded).trimStart(); + return text.length === 0 || text.startsWith("{"); +}; + +const readSourceMap = ( + state: ScanState, + map: SourceMapReference, + bytes: Buffer, +): void => { + const text = mapText(bytes); + if (text !== null) { + readSourceMapText(state, map, text); + return; + } + pushSample(state.mapSamples, map.location); + state.unparsedMaps += 1; +}; + +/** Read map text already decoded by the member's owner, such as a UTF-16 script. */ +const readSourceMapText = ( + state: ScanState, + map: SourceMapReference, + text: string, +): void => { + const inspected = inspectSourceMapSourceContent(text); + if (inspected.embedded) { + state.embedded += 1; + pushSample(state.embeddedPaths, map.location); + } + if (inspected.complete) { + pushSample(state.mapSamples, map.location); + if (!inspected.embedded) { + state.withoutSources += 1; + pushSample(state.plainPaths, map.location); + } + return; + } + // Linker maps share the suffix but are not JavaScript source maps. + // JSON-looking maps remain unresolved when decoding fails or hits a limit. + if (map.kind === "possible-linker-map" && !mayHaveSourceMapText(text)) { + noteLinkerMapGuess(state, map.location); + return; + } + pushSample(state.mapSamples, map.location); + state.unparsedMaps += 1; +}; + +/** + * A script role says how a member is loaded, not what its bytes are. A `.map` + * or script target whose text parses as a source map is read as one, so a + * page that loads `main.map` as a script cannot hide the sources it ships. + */ +const scriptHoldsMap = ( + state: ScanState, + member: MemberView, + text: string, +): boolean => { + if (!isJavaScriptMember(member)) return false; + if ( + !member.logical.toLowerCase().endsWith(".map") && + !isScriptTarget(state, member.location) + ) + return false; + let parsed: unknown; + try { + parsed = JSON.parse(sourceMapJsonText(text)); + } catch { + return false; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return false; + return ( + "version" in parsed && + ("mappings" in parsed || "sources" in parsed || "sections" in parsed) + ); +}; + +/** + * Read a script member as the source map it holds, from the text that + * recognized it, so a byte order mark decides the encoding for both reads. + */ +const readScriptAsMap = ( + state: ScanState, + member: MemberView, + bytes: Buffer, + text: string, +): void => { + const { hasSlot } = chargeMapRead(state); + if (hasSlot && bytes.length <= MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES) + readSourceMapText( + state, + { location: member.location, kind: "javascript-source-map" }, + text, + ); + else state.skippedMaps += 1; +}; + +const examineTextMember = async ( + state: ScanState, + member: MemberView, + context: ScanContext, +): Promise => { + // CSS can declare inline maps, but the directive parser only handles JavaScript. + if (isCssMember(member)) state.skippedMaps += 1; + const remaining = MAX_RELEASE_EXPOSURE_TEXT_BYTES - state.textBytes; + if (remaining <= 0) { + // A packaged member's name is not its format, so one whose header was + // never read may be a native image or package, as an unread `.bin` may. + if (member.packageFormat !== null && !member.kindStated) { + state.coverageUnknown = true; + pushLimitation( + state, + `Did not read ${member.location} past the text allowance to rule out a native image or package.`, + ); + } + state.skippedText += 1; + // A name is not a script's role: `runner.txt` may start with a Node + // shebang, and the header that would say so is past the allowance too, so + // any unread member that could hold a script leaves its maps unassessed. + if (!isCssMember(member)) state.skippedMaps += 1; + return true; + } + const bytesReadBefore = state.memberBytesRead; + const loaded = await member.read( + Math.min(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES, remaining), + ); + const packaged = packagedFormatOfRead(state, member, loaded); + if (packaged !== null) { + state.textBytes += state.memberBytesRead - bytesReadBefore; + if (packaged !== "asar") + return examinePackagedBinary( + state, + { ...member, format: packaged }, + context, + ); + noteNestedPackage(state, "asar", member.location); + return true; + } + // A package member's name is not its format. One kept only as a prefix may + // have its PE signature past that prefix, so it stays undecided, as on the + // map route; text read in full has no later bytes to decide it. + if ( + loaded.kind === "over_allowance" && + loaded.prefix !== null && + member.packageFormat !== null && + !member.kindStated + ) + noteUndecidedPe(state, member, loaded.prefix); + return examineLoadedText( + state, + scriptRoleOfRead(member, loaded), + loaded, + bytesReadBefore, + ); +}; + +/** + * A text member's name may not say it is a script: `runner.txt` can start + * with a Node shebang. The bytes this read returned decide, so every route + * into the text reader gives such a file its JavaScript role. + */ +const scriptRoleOfRead = ( + member: MemberView, + loaded: MemberRead, +): MemberView => { + if (holdsScripts(member)) return member; + const header = headerOfRead(loaded); + if (header === null || !hasNodeShebang(header)) return member; + member.completion.script = true; + return { ...member, kind: "javascript" }; +}; + +const examineLoadedText = ( + state: ScanState, + member: MemberView, + loaded: MemberRead, + bytesReadBefore: number, +): boolean => { + if (loaded.kind === "bytes") { + const text = decodeMemberText(loaded.bytes); + scanText(state, member, loaded.bytes.length, text); + if (holdsScripts(member)) { + if (text === null) state.skippedMaps += 1; + else if (scriptHoldsMap(state, member, text.text)) + readScriptAsMap(state, member, loaded.bytes, text.text); + else { + if (isJavaScriptMember(member)) + examineInlineMaps(state, member, text.text, member.logical); + // A page another page loads as a script still ships as a page. + if (isScriptPageMember(member)) + examineHtmlScripts(state, member, text.text); + } + } else if (!isCssMember(member) && nodeShebang(loaded.bytes) === undefined) + // A program named by an unresolved variable may be Node, so the + // member's maps stay unassessed rather than absent. + state.skippedMaps += 1; + } else { + // The allowance bounds the read, so a read that yielded no text still spends it. + state.textBytes += state.memberBytesRead - bytesReadBefore; + // A packaged member's name is not its format, so one whose header was + // never read may be a native image or package, as an unread `.bin` may. + if ( + loaded.kind === "unread" && + member.packageFormat !== null && + !member.kindStated + ) + noteUnreadBinary(state, member); + else state.skippedText += 1; + // A header cut short of its first line, or never read, cannot rule out a + // Node shebang, so a text-named member's maps stay unassessed as well. + if ( + holdsScripts(member) || + (!isCssMember(member) && shebangUndecided(headerOfRead(loaded))) + ) + state.skippedMaps += 1; + } + return loaded.kind !== "unread"; +}; + +/** + * Whether a header leaves a Node shebang undecided: none was read, it could + * start one and ends before the shebang line does, or its program is named by + * an environment variable. + */ +const shebangUndecided = (header: Buffer | null): boolean => + header === null || + ("#!".startsWith(header.toString("latin1", 0, 2)) && + !header.includes(0x0a)) || + nodeShebang(header) === undefined; + +/** + * A shebang that runs Node makes a file a script whatever it is called. The + * path classifier cannot see this, so the first bytes read establish it. + */ +const hasNodeShebang = (prefix: Buffer): boolean => + nodeShebang(prefix) === true; + +/** + * Whether a shebang runs Node. An `env -S` program whose name comes from a + * `${NAME}` the scan cannot resolve, such as `${NODE_BIN}`, may be Node, so + * it is undefined rather than false. + */ +const nodeShebang = (prefix: Buffer): boolean | undefined => { + if (prefix.toString("latin1", 0, 2) !== "#!") return false; + const [line = ""] = prefix.toString("latin1", 2).split("\n", 1); + const [, interpreter = "", operands = ""] = + /^(\S*)[ \t]*(.*)$/su.exec(line.trim()) ?? []; + const program = + basename(interpreter) === "env" + ? envProgram(operands) + : { text: interpreter, expands: false }; + if (program === undefined) return false; + const name = basename(program.text); + if (/^node(?:js)?$/u.test(name)) return true; + return program.expands && name.includes("${") ? undefined : false; +}; + +/** One `env` field, and whether `env -S` expands a `${NAME}` inside it. */ +interface EnvField { + readonly text: string; + readonly expands: boolean; +} + +/** `env` options whose argument is the next field, as in `-u NODE_OPTIONS`. */ +const ENV_OPTION_WITH_ARGUMENT = new Set([ + "-u", + "--unset", + "-C", + "--chdir", + "-P", +]); + +/** + * The program an `env` shebang runs: the first field that is neither an + * option, an option's argument, nor a `NAME=value` setting. `-S` takes the + * rest of the line as one string and splits it as `env` does, so quoted + * spaces stay inside their field. + */ +const envProgram = (operands: string): EnvField | undefined => { + const fields: EnvField[] = []; + let rest = operands; + const next = (): EnvField | undefined => { + if (fields.length > 0) return fields.shift(); + const [, field, after = ""] = /^[ \t]*([^ \t]+)(.*)$/su.exec(rest) ?? []; + rest = after; + return field === undefined ? undefined : { text: field, expands: false }; + }; + for (let entry = next(); entry !== undefined; entry = next()) { + const field = entry.text; + // Past `--` nothing is an option, but settings still come first. + if (field === "--") { + for (let word = next(); word !== undefined; word = next()) + if (!word.text.includes("=")) return word; + return undefined; + } + if (ENV_OPTION_WITH_ARGUMENT.has(field)) { + next(); + continue; + } + const attached = + /^-[0iv]*S(.*)$/su.exec(field)?.[1] ?? + (field.startsWith("--split-string=") + ? field.slice("--split-string=".length) + : undefined); + if (attached !== undefined) { + // The kernel passes the whole line as one operand, so an `-S` read from + // the line itself owns everything after it, separators included. + const fromLine = fields.length === 0; + const words = splitEnvString(fromLine ? attached + rest : attached); + if (words === undefined) return undefined; + if (fromLine) rest = ""; + fields.unshift(...words); + continue; + } + if (field.startsWith("-") || field.includes("=")) continue; + return entry; + } + return undefined; +}; + +/** Escapes `env -S` accepts outside single quotes, as GNU coreutils reads them. */ +const ENV_SPLIT_ESCAPES: Readonly> = { + "\\": "\\", + "'": "'", + '"': '"', + "#": "#", + $: "$", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + v: "\v", +}; + +/** + * Split an `env -S` string on its unquoted spaces and tabs. Single quotes keep + * text literally but for `\\` and `\'`; double quotes and bare text take the + * escapes above, `\_` separates fields outside quotes and is a space inside + * them, `\c` ends the string, and a `#` that starts a field begins a comment. + * `${NAME}` stays as written: it can only change a value, never which field is + * the program, so its field is marked as expanding. A string `env` itself + * rejects, such as an unterminated quote or an unknown escape, runs nothing. + */ +const splitEnvString = (text: string): EnvField[] | undefined => { + const fields: EnvField[] = []; + let field: string | null = null; + let expands = false; + let quote: "'" | '"' | null = null; + const end = (): void => { + if (field !== null) fields.push({ text: field, expands }); + field = null; + expands = false; + }; + for (let index = 0; index < text.length; index += 1) { + const character = text[index] ?? ""; + if (quote === "'") { + const escaped = text[index + 1]; + if (character === "'") quote = null; + else if (character === "\\" && (escaped === "\\" || escaped === "'")) { + field += escaped; + index += 1; + } else field += character; + continue; + } + if (character === "\\") { + const escaped = text[index + 1]; + index += 1; + if (escaped === "c") { + if (quote !== null) return undefined; + break; + } + if (escaped === "_") { + if (quote === null) end(); + else field += " "; + continue; + } + const value = + escaped === undefined ? undefined : ENV_SPLIT_ESCAPES[escaped]; + if (value === undefined) return undefined; + field = (field ?? "") + value; + continue; + } + if (character === "$" && text[index + 1] === "{") expands = true; + if (quote === '"') { + if (character === '"') quote = null; + else field += character; + continue; + } + if (character === " " || character === "\t") end(); + else if (character === "#" && field === null) break; + else if (character === "'" || character === '"') { + quote = character; + field ??= ""; + } else field = (field ?? "") + character; + } + if (quote !== null) return undefined; + end(); + return fields; +}; + +/** + * A name is not a format: a package member called `payload.json` or + * `payload.js.map` can be a PE or an ASAR, and a script or map declaration + * names whatever is at its target, and inventory's role for a member is a + * guess from that name. The route's own read decides, so real text and maps + * cost no further read. Only a kind the package owner stated is kept. + */ +const packagedFormatOfRead = ( + state: ScanState, + member: MemberView, + loaded: MemberRead, +): NativeFormat | "asar" | null => { + if (member.packageFormat === null || member.kindStated) return null; + if (loaded.kind === "unread") return null; + const header = loaded.kind === "bytes" ? loaded.bytes : loaded.prefix; + if (header === null) return null; + const native = sniffNativeFormat( + header, + loaded.kind === "bytes" ? header.length : member.declaredSize, + ); + if (native !== null) return native; + return probeNestedAsar( + state, + member.location, + header, + loaded.kind === "bytes", + ) + ? "asar" + : null; +}; + +/** + * Recognize text from bounded content. A name such as `.env.production`, + * `server.key`, or `credentials` is not a complete statement of what a file is. + * Content that could not be probed leaves text coverage undecided. + */ +const examineProbedText = async ( + state: ScanState, + member: MemberView, + probe: MemberRead, + context: ScanContext, +): Promise => { + const prefix = + probe.kind === "bytes" + ? probe.bytes + : probe.kind === "over_allowance" + ? probe.prefix + : null; + if (prefix === null) { + state.skippedText += 1; + // The header that would give a Node shebang its script role was never + // read, so the file's maps stay unassessed, as an unread `runner.js`'s do. + state.skippedMaps += 1; + } else if (hasNodeShebang(prefix)) { + // The probe already read the header, so the role holds even when the + // text allowance is spent before the text reader reads it again. + member.completion.script = true; + await examineTextMember(state, { ...member, kind: "javascript" }, context); + } else if ( + prefix.length > 0 && + (!prefix.includes(0) || declaresTextEncoding(prefix)) + ) + await examineTextMember(state, member, context); +}; + +/** + * A script element's body declares maps as a script file does. Bodies past the + * HTML owner's retained limit were not read, so each counts as a skipped map. + */ +const examineHtmlScripts = ( + state: ScanState, + member: MemberView, + source: string, +): void => { + const { baseHref, scripts, inlineScripts, inlineScriptCount } = + htmlArtifactReferences(source); + for (const { scriptPath, scriptType } of scripts) + if (scriptType !== null) + noteScriptReference(state, member, scriptPath, baseHref); + for (const script of inlineScripts) + examineInlineMaps( + state, + member, + script.text, + script.scriptType === "module" ? "inline.mjs" : "inline.js", + ); + state.skippedMaps += inlineScriptCount - inlineScripts.length; +}; + +/** + * A script element loads its target whatever the file is called, and a remote + * script is not shipped. Every local target is recorded so it is examined as a + * script when it is reached, and one that is never reached, such as a sibling + * of a selected file, stays unassessed rather than absent. A target that cannot + * be placed on disk stays unassessed. + */ +const noteScriptReference = ( + state: ScanState, + member: MemberView, + scriptPath: string, + baseHref: string | null, +): void => { + try { + const page = pathToFileURL(anchoredLocation(state, member.location)); + const url = new URL( + scriptPath, + baseHref === null ? page : new URL(baseHref, page), + ); + if (url.protocol === "http:" || url.protocol === "https:") return; + noteScriptTarget(state, fileURLToPath(url)); + } catch { + state.skippedMaps += 1; + } +}; + +/** + * A base href is copied into every target it resolves, so a page's paths can + * outgrow its text many times over. A target past the allowance stays unassessed. + * A target already read is not a linker map, whatever its first read guessed, + * and its script was not examined, so its source-map coverage stays unknown. + */ +const noteScriptTarget = (state: ScanState, location: string): void => { + const target = memberLocationKey(state, location); + const spellings = state.scriptTargets.get(target); + retractLinkerMapGuesses(state, location); + const spelling = anchoredLocation(state, location); + if (spellings?.has(spelling)) return; + state.scriptTargetBytes += Buffer.byteLength(spelling); + if (state.scriptTargetBytes > MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES) { + state.skippedMaps += 1; + state.mapTargetsDiscarded = true; + } else if (spellings === undefined) + state.scriptTargets.set(target, new Set([spelling])); + else spellings.add(spelling); +}; + +/** + * Whether a page loaded this exact location as a script. Another spelling in + * the same lookup bucket is a distinct file that keeps its own role. + */ +const isScriptTarget = (state: ScanState, location: string): boolean => + state.scriptTargets + .get(memberLocationKey(state, location)) + ?.has(anchoredLocation(state, location)) === true; + +const examineInlineMaps = ( + state: ScanState, + member: MemberView, + source: string, + sourcePath: string, +): void => { + const parsed = parseJavaScriptSource(source, sourcePath); + if (parsed === null) { + state.skippedMaps += 1; + return; + } + const directives = createJavaScriptAnalysisAccumulator(source.length); + addSourceMapDirectives(source, parsed.comments ?? [], directives); + for (const { declared_url: url } of directives.sourceMaps) { + // URL schemes are case-insensitive (RFC 3986 section 3.1). + if (!/^data:/iu.test(url)) { + noteLocalMapReference(state, member, url); + continue; + } + const comma = url.indexOf(","); + if (state.mapsRead >= MAX_RELEASE_EXPOSURE_SOURCE_MAPS || comma < 0) { + state.skippedMaps += 1; + continue; + } + try { + const metadata = url.slice(0, comma); + const payload = url.slice(comma + 1); + const decoded = /;base64$/iu.test(metadata) + ? Buffer.from(decodeURIComponent(payload), "base64") + : Buffer.from(decodeURIComponent(payload), "utf8"); + if (decoded.length > MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES) { + state.skippedMaps += 1; + continue; + } + state.mapsRead += 1; + // An inline declaration is a JavaScript map even if its JSON is invalid. + readSourceMap( + state, + { kind: "javascript-source-map", location: member.location }, + decoded, + ); + } catch { + state.unparsedMaps += 1; + } + } +}; + +/** + * A declaration names its map by location, not by file name. Every local + * target is recorded so it is examined as a map when it is reached, and one + * that is never reached, such as a sibling of a selected file, stays + * unassessed rather than absent. A target already + * examined as ordinary text was not assessed as a map, so coverage is unknown. + * A `file:` URL names one absolute location rather than a path beside the + * script, so even a `.map` target stays unassessed until that location is + * reached. Any other scheme is remote. + */ +const noteLocalMapReference = ( + state: ScanState, + member: MemberView, + url: string, +): void => { + if (/^file:/iu.test(url)) { + try { + noteMapTarget(state, fileURLToPath(url)); + } catch { + state.skippedMaps += 1; + } + return; + } + if (/^[a-z][a-z0-9+.-]+:/iu.test(url)) return; + const path = url.replace(/[?#].*$/su, ""); + if (path.length === 0) return; + let decoded: string; + try { + decoded = decodeURIComponent(path); + } catch { + state.skippedMaps += 1; + return; + } + noteMapTarget( + state, + resolve(dirname(anchoredLocation(state, member.location)), decoded), + ); +}; + +/** + * A short URL is expanded by the declaring member's directory, so declared + * paths can outgrow their text many times over. A target past the allowance + * stays unassessed. + */ +const noteMapTarget = (state: ScanState, location: string): void => { + const target = memberLocationKey(state, location); + // A map read before its declaration may have settled as a linker map. + state.skippedMaps += retractLinkerMapGuesses(state, location); + const spellings = state.mapTargets.get(target); + const spelling = anchoredLocation(state, location); + if (spellings?.has(spelling)) return; + state.mapTargetBytes += Buffer.byteLength(spelling); + if (state.mapTargetBytes > MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES) { + state.skippedMaps += 1; + state.mapTargetsDiscarded = true; + } else if (spellings === undefined) + state.mapTargets.set(target, new Set([spelling])); + else spellings.add(spelling); +}; + +/** + * Whether a declaration wrote this exact location. A lookup bucket alone + * does not prove which of its occurrences a declaration means. + */ +const claimMapTarget = (state: ScanState, location: string): boolean => { + const key = memberLocationKey(state, location); + if ( + state.mapTargets.get(key)?.has(anchoredLocation(state, location)) !== true + ) + return false; + state.claimedMapTargets.add(key); + return true; +}; + +/** + * Lookup identity for a member location. Inventory reports logical paths in + * NFC, so a declared target spelled in another normalization form still + * names the same member. The declared URL itself is left as written. + */ +const memberLocationKey = (state: ScanState, location: string): string => + logicalPathNfc(anchoredLocation(state, location)); + +/** + * Where a member location lives. A disk location is already absolute; an + * expanded package member's location is relative to the selected path. + */ +const anchoredLocation = (state: ScanState, location: string): string => + resolve(state.anchor, location); + +/** Retract one source occurrence; a normalized lookup bucket is not identity. */ +const retractLinkerMapGuesses = ( + state: ScanState, + location: string, +): number => { + const key = memberLocationKey(state, location); + const paths = state.linkerMapGuesses.get(key); + if (paths === undefined) return 0; + const spelling = anchoredLocation(state, location); + const guess = [...paths].find( + (path) => anchoredLocation(state, path) === spelling, + ); + if (guess !== undefined) { + paths.delete(guess); + if (paths.size === 0) state.linkerMapGuesses.delete(key); + return 1; + } + // Without an exact source spelling, normalization cannot prove which + // occurrence the declaration means. Keep companion coverage unresolved. + state.skippedCompanions += paths.size; + state.linkerMapGuesses.delete(key); + return paths.size; +}; + +const noteLinkerMapGuess = (state: ScanState, location: string): void => { + // Once a map or script declaration was discarded, this map may be its target. + if (state.mapTargetsDiscarded) { + state.skippedMaps += 1; + state.skippedCompanions += 1; + return; + } + const key = memberLocationKey(state, location); + const paths = state.linkerMapGuesses.get(key) ?? new Set(); + state.linkerMapGuesses.set(key, paths.add(location)); +}; + +/** + * An owner's stated role or a declaration outranks the member's name, and an + * inventory path role is only that name: a `.map` that an owner marks as + * JavaScript or a page loads as a script is not a map. + */ +const isDeclaredMap = (state: ScanState, member: MemberView): boolean => { + if (member.kindStated && member.kind === "source-map") return true; + const key = memberLocationKey(state, member.location); + if (state.mapTargets.has(key)) return true; + if (member.kind === "javascript" || isScriptTarget(state, member.location)) + return false; + return isSourceMap(member.logical, member.kind); +}; + +/** Keep normalized lookup separate from exact spelling and source occurrence. */ +const recordMemberCompletion = ( + state: ScanState, + location: string, + completion: MemberCompletion, +): void => { + const key = memberLocationKey(state, location); + const bucket = + state.examinedMembers.get(key) ?? new Map>(); + const spelling = anchoredLocation(state, location); + const members = bucket.get(spelling) ?? new Set(); + members.add(completion); + bucket.set(spelling, members); + state.examinedMembers.set(key, bucket); +}; + +/** A path reference is complete only when every exact matching source occurrence was examined in that role. */ +const completedTarget = ( + state: ScanState, + location: string, + role: keyof MemberCompletion, +): boolean => { + const bucket = state.examinedMembers.get(memberLocationKey(state, location)); + const exact = bucket?.get(anchoredLocation(state, location)); + // Inventory normalizes some package paths. Without an exact spelling, + // completion holds only if every candidate in that lookup bucket has it. + const candidates = + exact === undefined + ? [...(bucket?.values() ?? [])].flatMap((members) => [...members]) + : [...exact]; + return candidates.length > 0 && candidates.every((member) => member[role]); +}; + +/** + * A declared map or script target that was never reached was not assessed. + * When no declared spelling was reached, another spelling of the target may + * be the declared map, so its linker-map guess is no companion evidence. + */ +const noteUnreachedTargets = (state: ScanState): void => { + for (const [target, spellings] of state.mapTargets) { + const examined = [...spellings].every((spelling) => + completedTarget(state, spelling, "map"), + ); + if (!examined) state.skippedMaps += 1; + if (state.claimedMapTargets.has(target)) continue; + const guesses = state.linkerMapGuesses.get(target); + if (guesses === undefined) continue; + state.linkerMapGuesses.delete(target); + state.skippedCompanions += guesses.size; + if (examined) state.skippedMaps += guesses.size; + } + for (const spellings of state.scriptTargets.values()) + if ( + ![...spellings].every((spelling) => + completedTarget(state, spelling, "script"), + ) + ) + state.skippedMaps += 1; +}; + +/** Text decoded from a member; `complete` is false when part of it went unread. */ +interface MemberText { + readonly text: string; + readonly complete: boolean; +} + +/** + * A binary plist that cannot be fully decoded keeps its raw bytes in the scan, + * for the ASCII strings they show, beside whatever text it did decode, but its + * other strings were not examined. + */ +const decodeBinaryPlistMember = (bytes: Buffer): MemberText => { + try { + const decoded = decodeBinaryPlistText( + bytes, + RELEASE_EXPOSURE_PLIST_DECODE_BYTES, + ); + return decoded.complete + ? decoded + : { text: `${decoded.text}\n${bytes.toString("utf8")}`, complete: false }; + } catch { + return { text: bytes.toString("utf8"), complete: false }; + } +}; + +/** + * Bytes that name their own text encoding, whose NULs are that encoding's + * structure rather than a sign of binary data. `decodeMemberText` reads each of + * these; a probe that admits on NULs alone would drop them unexamined. + */ +const declaresTextEncoding = (bytes: Buffer): boolean => + byteOrderMarkEncoding(bytes) !== null || hasBinaryPlistSignature(bytes); + +/** + * The text a member's bytes encode, or null when they cannot be read as text. + * A member's kind says nothing about its encoding: wide text has no byte order + * mark to explain its NULs, and a scan of it would match nothing and still count + * as complete. A NUL inside UTF-8 text does not change how the rest of it reads. + * A binary plist has its own object encoding, so its owner decodes it. + */ +const decodeMemberText = (bytes: Buffer): MemberText | null => { + const encoding = byteOrderMarkEncoding(bytes); + if (encoding === "utf-32") return null; + if (encoding !== null) + return { text: new TextDecoder(encoding).decode(bytes), complete: true }; + if (hasBinaryPlistSignature(bytes)) return decodeBinaryPlistMember(bytes); + if (hasNonUtf8Nul(bytes)) return null; + return { text: bytes.toString("utf8"), complete: true }; +}; + +const scanText = ( + state: ScanState, + member: MemberView, + byteLength: number, + text: MemberText | null, +): void => { + if ( + byteLength > MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES || + state.textBytes + byteLength > MAX_RELEASE_EXPOSURE_TEXT_BYTES + ) { + state.skippedText += 1; + return; + } + state.textBytes += byteLength; + if (text === null) { + state.skippedText += 1; + return; + } + if (text.complete) state.scannedText += 1; + else state.skippedText += 1; + state.visitText(text.text, member.location); +}; + +/** + * Probe a package member that is neither text nor a map. Only its header is + * read unless the bytes declare a PE, so large binary members cost no budget + * that a later source map or text member needs. + */ +const examinePackagedBinary = async ( + state: ScanState, + member: MemberView, + context: ScanContext, +): Promise => { + const probe = await member.read(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES); + if (probe.kind === "unread") { + // `examineMember` counts this failed read. + noteUnreadBinary(state, member, false); + return false; + } + const header = probe.kind === "bytes" ? probe.bytes : probe.prefix; + const native = + asNativeFormat(member.format) ?? + (header === null + ? null + : sniffNativeFormat( + header, + probe.kind === "bytes" ? header.length : member.declaredSize, + )); + if (native === null && header !== null) + noteUndecidedPe(state, member, header); + if ( + native === null && + header !== null && + probeNestedAsar(state, member.location, header, probe.kind === "bytes") + ) { + noteNestedPackage(state, "asar", member.location); + return true; + } + if (native !== null) { + state.packagedNative += 1; + await probePackagedImage(state, member, native, context); + } else await examineProbedText(state, member, probe, context); + if (native !== "pe") return true; + if (state.packagedPeReads >= MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS) { + notePeNotExamined(state, member.location); + return true; + } + state.packagedPeReads += 1; + const image = + probe.kind === "bytes" + ? probe + : await member.read(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES); + if (image.kind === "bytes") readPdb(state, image.bytes, member.location); + else notePeNotExamined(state, member.location); + return image.kind !== "unread"; +}; + +/** + * An `MZ` header whose PE signature lies past the bytes read may be native, so + * the image stays unexamined by the on-disk readers, and it may carry a + * package that nothing here can clear. Returns whether the header was undecided. + */ +/** An `MZ` header whose PE signature lies past the bytes read, so it may be native. */ +const isUndecidedPeHeader = (header: Buffer): boolean => { + if (header.toString("latin1", 0, 2) !== "MZ") return false; + const windows = observeMzWindowsHeader(header); + return ( + windows.state === "signature-needed" || + (windows.state === "candidate" && windows.signature === "unobserved") + ); +}; + +const noteUndecidedPe = ( + state: ScanState, + member: Pick, + header: Buffer, +): boolean => { + if (!isUndecidedPeHeader(header)) return false; + state.packagedNative += 1; + notePeNotExamined(state, member.location); + noteUnexaminedPackage(state); + return true; +}; + +const readPdb = (state: ScanState, bytes: Buffer, imagePath: string): void => { + const header = observeMzWindowsHeader(bytes); + if (header.state !== "declared" || header.signature !== "PE") { + state.pdbFailed += 1; + return; + } + state.pdbExamined += 1; + try { + const parsed = inspectPeCodeViewFromBuffer(bytes, header.offset); + if (!parsed.ok) { + state.pdbFailed += 1; + pushLimitation(state, `${imagePath}: ${parsed.error.message}`); + } else if (parsed.value !== undefined && parsed.value.pdbPath.length > 0) { + const record = parsed.value; + pushSample(state.pdbPaths, record.pdbPath); + pushSample(state.pdbImagePaths, imagePath); + } + } catch { + state.pdbFailed += 1; + } +}; + +const examinePdb = async ( + filePath: string, + expectedSha256: string | undefined, + state: ScanState, + { signal, resources, maxFileBytes }: ScanContext, +): Promise => { + if (state.pdbAttempts >= PDB_READ_LIMIT) { + state.pdbFailed += 1; + return; + } + state.pdbAttempts += 1; + try { + const snapshot = await readStableArtifact( + filePath, + maxFileBytes, + signal, + undefined, + resources, + ); + if (expectedSha256 !== undefined && snapshot.sha256 !== expectedSha256) + throw new ArtifactReaderFailure( + "integrity", + `Member changed during acquisition: ${filePath}`, + ); + readPdb(state, snapshot.bytes, filePath); + } catch (cause) { + rethrowReadFailure(cause); + state.pdbFailed += 1; + pushLimitation(state, `Could not read PE debug records from ${filePath}.`); + } +}; + +const readOccurrence = async ( + input: ConsumeInput, + occurrence: InventoryOccurrence, + ordinals: ReadonlyMap, + containers: Map, + byId: Map, + formatOf: (occurrence: InventoryOccurrence) => string, + nodeSha: Map, + state: ScanState, + budget: number, +): Promise => { + const parent = + occurrence.parent_occurrence_id === null + ? undefined + : byId.get(occurrence.parent_occurrence_id); + const packaged = packageAncestor(occurrence, byId, formatOf); + const container = packaged ?? parent; + const containerFormat = container === undefined ? null : formatOf(container); + const extractable = + containerFormat !== null && EXTRACTABLE.has(containerFormat); + const compressed = + occurrence.compressed_size !== null && occurrence.compressed_size > 0; + const located = occurrence.source_location; + if ( + extractable && + container !== undefined && + containerFormat !== null && + (compressed || located === null) + ) { + const containerPath = diskPath(input, container, byId, formatOf); + if (containerPath === null) return { kind: "unread" }; + return readContainerMember( + input, + container, + containerPath, + containerFormat, + shaOf(container, nodeSha), + shaOf(occurrence, nodeSha), + occurrence, + ordinals, + containers, + budget, + state, + ); + } + if (located !== null && !compressed && parent !== undefined) { + if ( + input.snapshot !== null && + parent.parent_occurrence_id === null && + located.offset >= 0 && + located.offset + located.length <= input.snapshot.length + ) + return fitAllowance( + input.snapshot.subarray( + located.offset, + located.offset + located.length, + ), + budget, + ); + const parentPath = diskPath(input, parent, byId, formatOf); + if (parentPath !== null) { + const bytes = await readSpan( + parentPath, + located.offset, + Math.min(located.length, budget), + state, + input, + ); + if (bytes === null) return { kind: "unread" }; + return located.length > budget + ? overAllowance(bytes) + : { kind: "bytes", bytes }; + } + } + const filePath = diskPath(input, occurrence, byId, formatOf); + if (filePath === null) return { kind: "unread" }; + if (input.snapshot !== null && filePath === input.path) + return fitAllowance(input.snapshot, budget); + return readDiskMember( + filePath, + budget, + shaOf(occurrence, nodeSha), + state, + input, + ); +}; + +type EmbeddedPackageProbe = + | { readonly kind: "package"; readonly format: PackageFormat } + | { readonly kind: "absent" } + | { readonly kind: "unreadable" }; + +/** + * Own one member descriptor. A failed close stays retained by the resource + * scope, and the primary failure survives it. + */ +const withMemberDescriptor = async ( + filePath: string, + { resources, signal }: ScanContext, + use: (source: StableRegularFileDescriptor) => Promise, +): Promise => { + const source = await openRootArtifact(filePath, resources, signal); + let settled: Result; + try { + settled = ok(await use(source)); + } catch (cause) { + settled = err(asReadFailure(cause)); + } + await releaseRootArtifact( + filePath, + source.owner, + resources, + settled.ok + ? { kind: "completed", digest: null } + : { kind: "failed", cause: settled.error }, + signal, + ); + if (!settled.ok) throw settled.error; + return settled.value; +}; + +const embeddedPackageFormat = async ( + filePath: string, + nativeFormat: NativeFormat, + state: ScanState, + context: ScanContext, + location = filePath, + expectedSha256?: string, +): Promise => { + try { + return await withMemberDescriptor(filePath, context, async (source) => { + if (source.initial.size > context.maxFileBytes) { + pushLimitation( + state, + `Did not probe ${location} for an embedded package because it exceeds ${String(context.maxFileBytes)} bytes.`, + ); + return { kind: "unreadable" }; + } + const probes = new ArtifactEmbeddedFormatProbes( + filePath, + source, + context.signal, + ); + const classified = await classifyEmbeddedExecutablePackage( + probes, + nativeFormat, + context.signal, + ); + if (classified !== undefined) + return { kind: "package", format: classified.format }; + // A negative probe clears member coverage, so it only stands for the + // image inventory hashed: a replacement is a changed member, not an + // image without a package. + if (expectedSha256 !== undefined) { + const digest = await hashStableRootArtifactHandle( + filePath, + source.handle, + source.initial, + context.signal, + ); + if (digest.sha256 !== expectedSha256) + throw new ArtifactReaderFailure( + "integrity", + `Member changed during acquisition: ${filePath}`, + ); + } + return { kind: "absent" }; + }); + } catch (cause) { + rethrowReadFailure(cause); + pushLimitation( + state, + `Could not probe ${location} for an embedded package.`, + ); + return { kind: "unreadable" }; + } +}; + +/** + * The embedded-package probes read carrier offsets from a file, and a package + * member has no file of its own, so its bytes stay on disk for the probe alone. + */ +const embeddedPackageFormatOfBytes = async ( + bytes: Buffer, + member: MemberView, + nativeFormat: NativeFormat, + state: ScanState, + context: ScanContext, +): Promise => { + try { + const root = await mkdtemp(join(tmpdir(), "rea-release-member-")); + const owner: ArtifactResourceOwner = { + kind: "lease", + resource: root, + lease: { release: () => rm(root, { force: true, recursive: true }) }, + }; + let settled: Result; + try { + const filePath = join(root, "member"); + await writeFile(filePath, bytes, { flag: "wx", mode: 0o600 }); + settled = ok( + await embeddedPackageFormat( + filePath, + nativeFormat, + state, + context, + member.location, + ), + ); + } catch (cause) { + settled = err(cause); + } + // The directory holds the member's bytes, so a failed removal stays owned. + const removal = await context.resources.release(owner); + if (removal.kind === "failed") + throw context.resources.cleanupFailure( + settled.ok + ? { kind: "completed" } + : { kind: "failed", cause: settled.error }, + [removal], + ); + if (!settled.ok) throw settled.error; + return settled.value; + } catch (cause) { + rethrowReadFailure(cause); + pushLimitation( + state, + `Could not probe ${member.location} for an embedded package.`, + ); + return { kind: "unreadable" }; + } +}; + +/** + * A packaged executable can carry its own package, whose members this scan does + * not expand. Only an image the embedded-package owner cleared leaves member + * coverage intact. All members share one file's byte allowance. + */ +const probePackagedImage = async ( + state: ScanState, + member: MemberView, + nativeFormat: NativeFormat, + context: ScanContext, +): Promise => { + const allowance = context.maxFileBytes - state.packagedProbeBytes; + const image = allowance > 0 ? await member.read(allowance) : null; + if (image?.kind !== "bytes") { + state.packagedProbeBytes = context.maxFileBytes; + state.unprobedImages += 1; + noteUnexaminedPackage(state); + pushLimitation( + state, + `Did not probe ${member.location} for an embedded package.`, + ); + return; + } + state.packagedProbeBytes += image.bytes.length; + const probe = await embeddedPackageFormatOfBytes( + image.bytes, + member, + nativeFormat, + state, + context, + ); + if (probe.kind === "unreadable") noteUnexaminedPackage(state); + else if (probe.kind === "package") + noteNestedPackage(state, probe.format, member.location); +}; + +const scanEmbeddedPackage = async ( + input: ConsumeInput, + occurrence: InventoryOccurrence, + filePath: string, + nativeFormat: NativeFormat, + containers: Map, + state: ScanState, +): Promise => { + if (state.embeddedProbes >= EMBEDDED_PROBE_LIMIT) { + noteUnexaminedPackage(state); + state.unprobedImages += 1; + pushLimitation( + state, + `Stopped probing native images for embedded packages after ${String(EMBEDDED_PROBE_LIMIT)} files.`, + ); + return; + } + state.embeddedProbes += 1; + const probe = await embeddedPackageFormat( + filePath, + nativeFormat, + state, + input, + filePath, + state.nativeDigests.get(filePath), + ); + if (probe.kind === "unreadable") { + noteUnexaminedPackage(state); + return; + } + if (probe.kind === "absent") return; + state.formats.add(probe.format); + await scanUnexpandedContainer( + input, + occurrence, + filePath, + probe.format, + containers, + state, + ); +}; + +const scanUnexpandedContainer = async ( + input: ConsumeInput, + occurrence: InventoryOccurrence, + filePath: string, + format: PackageFormat, + containers: Map, + state: ScanState, +): Promise => { + let entries: readonly ArtifactEntry[]; + try { + const container = await openContainer( + containers, + occurrence.occurrence_id, + filePath, + format, + input.inventory?.nodes.find( + (node) => node.artifact_id === occurrence.artifact_id, + )?.sha256, + state, + input.signal, + ); + if (container === null) { + noteUnexaminedPackage(state); + return; + } + entries = await containerEntries( + container, + state, + input.signal, + (entry) => { + noteUnlistedEntry(state, entry); + }, + ); + } catch (cause) { + rethrowReadFailure(cause); + noteUnexaminedPackage(state); + pushLimitation(state, `Could not list ${filePath}.`); + return; + } + for (const entry of entries) { + if (input.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: input.signal.reason, + }); + const location = `${filePath}/${entry.path}`; + if (hasCompanionName(entry.path)) state.companionPaths.add(location); + noteUnfollowedMapLink(state, entry.kind, entry.path, location); + if (entry.kind !== "file") continue; + const stated = ownerStatedKind(entry.formatMetadata); + await examineMember(state, input, { + logical: entry.path, + format: stated === "javascript" ? "javascript-bundle" : "file", + kind: stated ?? "file", + kindStated: stated !== null, + encrypted: entry.encrypted, + declaredSize: entry.declaredSize, + location, + packageFormat: format, + read: (budget) => + readListedEntry( + containers.get(occurrence.occurrence_id), + entry, + filePath, + budget, + state, + input.signal, + ), + }); + } +}; + +/** An unlisted package leaves every member-based check undecided. */ +const noteUnexaminedPackage = (state: ScanState): void => { + state.unreadable += 1; + state.coverageUnknown = true; +}; + +const noteNestedPackage = ( + state: ScanState, + format: PackageFormat, + location: string, +): void => { + state.formats.add(format); + noteUnexaminedPackage(state); + pushLimitation(state, `Nested package ${location} was not expanded.`); +}; + +/** + * The ASAR owner's signature test. A match does not establish a valid archive. + * A probe that stops at the header admission, or runs out of a prefix read + * before it decides, leaves the member undecided rather than not an ASAR: the + * member may still carry a package nothing here reads, so coverage becomes + * unknown while the member is examined as whatever else it may be. + */ +const probeNestedAsar = ( + state: ScanState, + location: string, + bytes: Buffer, + complete: boolean, +): boolean => { + const observed = createAsarHeaderSignatureProbe( + state.asarHeaderAdmission, + ).write(bytes); + if (observed.state === "matched") return true; + if ( + observed.state === "limit" || + (observed.state === "pending" && + !complete && + observed.extents !== undefined) + ) { + noteUnexaminedPackage(state); + pushLimitation( + state, + `Could not decide whether ${location} holds an ASAR within the header admission.`, + ); + } + return false; +}; + +const readListedEntry = async ( + container: OpenContainer | undefined, + entry: ArtifactEntry, + filePath: string, + budget: number, + state: ScanState, + signal: AbortSignal | undefined, +): Promise => { + if (container === undefined) return { kind: "unread" }; + try { + return await readEntryBytes(container.reader, entry, budget, state, signal); + } catch (cause) { + rethrowReadFailure(cause); + pushLimitation(state, `Could not read ${entry.path} from ${filePath}.`); + return { kind: "unread" }; + } +}; + +const readContainerMember = async ( + input: ConsumeInput, + parent: InventoryOccurrence, + parentPath: string, + parentFormat: string, + expectedSha256: string | undefined, + memberSha256: string | undefined, + occurrence: InventoryOccurrence, + ordinals: ReadonlyMap, + containers: Map, + budget: number, + state: ScanState, +): Promise => { + const format = asPackageFormat(parentFormat); + if (format === null) return { kind: "unread" }; + try { + const container = await openContainer( + containers, + parent.occurrence_id, + parentPath, + format, + expectedSha256, + state, + input.signal, + ); + if (container === null) return { kind: "unread" }; + await containerEntries(container, state, input.signal); + const entry = container.byPath.get(entryPath(occurrence, parent))?.[ + ordinals.get(occurrence.occurrence_id) ?? 0 + ]; + if (entry === undefined || entry.kind !== "file") return { kind: "unread" }; + // The container digest binds packed members to the captured snapshot. An + // unpacked member is read from its companion on disk, so it is bound to + // the digest inventory recorded for that member instead. + return await readEntryBytes( + container.reader, + entry, + budget, + state, + input.signal, + entry.unpacked === true && memberSha256 !== undefined + ? { + sha256: memberSha256, + changed: `ASAR member changed before interpretation: ${occurrence.logical_path}`, + } + : undefined, + ); + } catch (cause) { + rethrowReadFailure(cause); + pushLimitation(state, `Could not read ${occurrence.logical_path}.`); + return { kind: "unread" }; + } +}; + +const openContainer = async ( + containers: Map, + id: string, + filePath: string, + format: PackageFormat, + expectedSha256: string | undefined, + state: ScanState, + signal: AbortSignal | undefined, +): Promise => { + const cached = containers.get(id); + if (cached !== undefined) return cached; + if (containers.size >= OPEN_CONTAINER_LIMIT) { + pushLimitation( + state, + `Stopped opening package readers after ${String(OPEN_CONTAINER_LIMIT)} containers.`, + ); + return null; + } + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { cause: signal.reason }); + const reader = createArtifactReader(filePath, format, { + environment: process.env, + unreadableDirectories: "omit", + asarHeaderAdmission: state.asarHeaderAdmission, + }); + if (reader === undefined) return null; + const container: OpenContainer = { + path: filePath, + reader, + entries: null, + byPath: new Map(), + }; + containers.set(id, container); + if (expectedSha256 !== undefined) + await reader.prepareContainer?.(expectedSha256, signal); + return container; +}; + +const containerEntries = async ( + container: OpenContainer, + state: ScanState, + signal: AbortSignal | undefined, + onPastCap?: (entry: ArtifactEntry) => void, +): Promise => { + if (container.entries !== null) return container.entries; + const listed: ArtifactEntry[] = []; + const traversal = new ArtifactTraversalObservation(); + let count = 0; + let truncated = false; + for await (const entry of container.reader.entries(signal, traversal)) { + count += 1; + if (count > CONTAINER_LIST_LIMIT) { + if (!truncated) { + truncated = true; + state.coverageUnknown = true; + pushLimitation( + state, + `Stopped listing a package after ${String(CONTAINER_LIST_LIMIT)} entries.`, + ); + } + onPastCap?.(entry); + continue; + } + listed.push(entry); + const key = logicalPathNfc(entry.path).replace(/\/+$/u, ""); + const sameName = container.byPath.get(key); + if (sameName === undefined) container.byPath.set(key, [entry]); + else sameName.push(entry); + } + const coverage = traversal.snapshot(); + if (coverage.status === "unknown") state.coverageUnknown = true; + for (const limitation of coverage.limitations) + pushLimitation(state, limitation); + container.entries = listed; + return listed; +}; + +const readEntryBytes = async ( + reader: ArtifactReader, + entry: ArtifactEntry, + budget: number, + state: ScanState, + signal: AbortSignal | undefined, + expected?: ExpectedMemberDigest, +): Promise => { + const stream = await reader.open(entry, signal); + return readStreamBounded(stream, budget, state, signal, expected); +}; + +/** An inventoried digest the member's complete bytes must still match. */ +interface ExpectedMemberDigest { + readonly sha256: string; + readonly changed: string; +} + +const readStreamBounded = async ( + stream: Readable, + budget: number, + state: ScanState, + signal: AbortSignal | undefined, + expected?: ExpectedMemberDigest, +): Promise => { + const chunks: Buffer[] = []; + const hash = expected === undefined ? undefined : createHash("sha256"); + let total = 0; + let overBudget = false; + let failure: unknown; + try { + for await (const chunk of stream) { + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { cause: signal.reason }); + const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(piece); + total += piece.length; + state.memberBytesRead += piece.length; + if (total > budget) { + overBudget = true; + break; + } + hash?.update(piece); + } + // The digest covers the whole member, so it is only checked when the + // member ended within the allowance. A member past the allowance is never + // read further to finish the hash: it stays over_allowance, unverified. + if ( + !overBudget && + expected !== undefined && + hash !== undefined && + hash.digest("hex") !== expected.sha256 + ) + throw new ArtifactReaderFailure("integrity", expected.changed); + } catch (cause) { + failure = cause; + } + let closeFailure: unknown; + try { + stream.destroy(); + } catch (cause) { + closeFailure = cause; + } + if (failure !== undefined && closeFailure !== undefined) + throw new AggregateError( + [failure, closeFailure], + "Member read and stream close both failed.", + ); + if (closeFailure !== undefined) throw closeFailure; + if (failure !== undefined) throw failure; + const bytes = Buffer.concat(chunks); + return overBudget ? overAllowance(bytes) : { kind: "bytes", bytes }; +}; + +const readSpan = async ( + filePath: string, + offset: number, + length: number, + state: ScanState, + context: ScanContext, +): Promise => { + const { signal } = context; + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length < 0 || + !Number.isSafeInteger(offset + length) + ) + return null; + try { + return await withMemberDescriptor( + filePath, + context, + async ({ handle, initial }) => { + const observed = await handle.stat(); + if (!sameRegularFileState(initial, observed)) + throw new ArtifactReaderFailure( + "integrity", + `Artifact changed before read: ${filePath}`, + ); + if (offset + length > observed.size) return null; + const bytes = await readFileHandleRange(handle, offset, length, () => { + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: signal.reason, + }); + }); + const after = await handle.stat(); + if (!sameRegularFileState(initial, after)) + throw new ArtifactReaderFailure( + "integrity", + `Artifact changed during read: ${filePath}`, + ); + state.memberBytesRead += bytes.length; + return Buffer.from(bytes); + }, + ); + } catch (cause) { + rethrowReadFailure(cause); + pushLimitation(state, `Could not read ${filePath}.`); + return null; + } +}; + +const readDiskMember = async ( + filePath: string, + maxBytes: number, + expectedSha256: string | undefined, + state: ScanState, + context: ScanContext, +): Promise => { + try { + const snapshot = await readStableArtifact( + filePath, + maxBytes, + context.signal, + undefined, + context.resources, + ); + if (expectedSha256 !== undefined && snapshot.sha256 !== expectedSha256) + throw new ArtifactReaderFailure( + "integrity", + `Member changed during acquisition: ${filePath}`, + ); + return { kind: "bytes", bytes: snapshot.bytes }; + } catch (cause) { + rethrowReadFailure(cause); + if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") + return { + kind: "over_allowance", + prefix: await readSpan( + filePath, + 0, + Math.min(maxBytes, MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES), + state, + context, + ), + }; + pushLimitation(state, `Could not read ${filePath}.`); + return { kind: "unread" }; + } +}; + +const asReadFailure = (cause: unknown): unknown => + cause instanceof RegularFileChangedError + ? new ArtifactReaderFailure("integrity", cause.message, { cause }) + : cause; + +/** + * A read that left cleanup unfinished is never a skipped member, whatever its + * reason: only the error tells the caller a descriptor is still retained. + */ +const rethrowReadFailure = (cause: unknown): void => { + const failure = asReadFailure(cause); + if (failure instanceof AnalysisError) throw failure; + if ( + failure instanceof ArtifactReaderFailure && + (failure.reason === "cancelled" || + failure.reason === "integrity" || + failure.cleanup !== undefined) + ) + throw failure; +}; + +/** + * List the parent directory up to the sibling allowance. The stream stops at + * the allowance, so a very large directory costs no more than the limit. + */ +const listSiblings = async ( + directory: string, + state: ScanState, + { signal, resources, siblingCleanup }: ScanContext, +): Promise => { + let owner: OwnedDirectoryHandle; + try { + owner = new OwnedDirectoryHandle(await opendir(directory)); + } catch { + noteIncompleteSiblings(state); + return []; + } + const entries: Dirent[] = []; + let listed: Result; + try { + let truncated = false; + for (;;) { + if (signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { cause: signal.reason }); + const entry = await owner.handle.read(); + if (entry === null) break; + if (entries.length >= SIBLING_LIST_LIMIT) { + truncated = true; + break; + } + entries.push(entry); + } + listed = ok(truncated); + } catch (cause) { + listed = err(cause); + } + const attempt = await resources.release({ + kind: "directory-handle", + resource: `directory beside the selected file: ${directory}`, + handle: owner, + }); + if (!listed.ok && listed.error instanceof AnalysisError) { + if (attempt.kind === "failed") + throw resources.cleanupFailure({ kind: "failed", cause: listed.error }, [ + attempt, + ]); + throw listed.error; + } + if (attempt.kind === "failed") siblingCleanup.push(attempt); + if (!listed.ok || listed.value) noteIncompleteSiblings(state); + return entries; +}; + +const noteIncompleteSiblings = (state: ScanState): void => { + state.skippedCompanions += 1; + pushLimitation( + state, + "The directory beside the selected file could not be listed completely.", + ); +}; + +const scanSiblings = async ( + input: ConsumeInput, + state: ScanState, +): Promise => { + const directory = dirname(input.path); + const own = basename(input.path); + for (const entry of await listSiblings(directory, state, input)) { + if (input.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { + cause: input.signal.reason, + }); + if (entry.name === own) continue; + const sibling = appendCallerPath(directory, entry.name); + if (hasCompanionName(entry.name)) { + state.companionPaths.add(sibling); + continue; + } + if (!entry.name.toLowerCase().endsWith(".map")) continue; + if (isJavaScriptSourceMapName(entry.name)) continue; + // A declared map stays a source map and a loaded script stays a script, + // and `noteUnreachedTargets` counts either one. + if (claimMapTarget(state, sibling) || isScriptTarget(state, sibling)) + continue; + // A link is not followed, so a linker map it names stays unread, not absent. + if (entry.isSymbolicLink()) { + state.skippedCompanions += 1; + continue; + } + if (!entry.isFile()) continue; + await classifySiblingMap(sibling, state, input); + } +}; + +/** + * Siblings are in scope only for debug-companion discovery, so a sibling map + * never reaches the selected artifact's source-map counts. Its read still + * spends the same map count, or a directory of .map files would multiply the + * byte allowance by the listing allowance. + */ +const classifySiblingMap = async ( + sibling: string, + state: ScanState, + input: ConsumeInput, +): Promise => { + const { hasSlot, bytes } = chargeMapRead(state); + const loaded = await readDiskMember(sibling, bytes, undefined, state, input); + if (loaded.kind === "unread") { + state.skippedCompanions += 1; + return; + } + const prefix = loaded.kind === "bytes" ? loaded.bytes : loaded.prefix; + if (loaded.kind === "bytes" && hasSlot && parsesAsSourceMap(loaded.bytes)) + return; + // A native header outranks the name, as it does for a member. + if ( + prefix !== null && + sniffNativeFormat( + prefix, + loaded.kind === "bytes" ? prefix.length : null, + ) !== null + ) + return; + // A Node shebang names a script, as it does for a member's `.map`. + if (prefix !== null && hasNodeShebang(prefix)) return; + // An undecided PE header may be native, so it is no linker-map evidence, + // nor is a shebang whose program an unresolved variable names. + if ( + prefix === null || + isUndecidedPeHeader(prefix) || + mayHaveSourceMapPrefix(prefix) || + nodeShebang(prefix) === undefined + ) + state.skippedCompanions += 1; + else noteLinkerMapGuess(state, sibling); +}; + +const parsesAsSourceMap = (bytes: Buffer): boolean => { + const text = mapText(bytes); + if (text === null) return false; + try { + inspectSourceMap(text); + return true; + } catch { + return false; + } +}; + +const diskPath = ( + input: ConsumeInput, + occurrence: InventoryOccurrence, + byId: Map, + formatOf: (occurrence: InventoryOccurrence) => string, +): string | null => { + if (hasExtractableAncestor(occurrence, byId, formatOf)) return null; + if ( + occurrence.source_path !== undefined && + occurrence.source_path.length > 0 && + isAbsolute(occurrence.source_path) + ) + return occurrence.source_path; + if (!input.directory) { + if (occurrence.parent_occurrence_id === null) return input.path; + return null; + } + if (occurrence.logical_path.split("/").includes("..")) return null; + if (occurrence.logical_path.length === 0 || occurrence.logical_path === ".") + return input.path; + const source = occurrence.source_path; + return appendCallerPath( + input.path, + source !== undefined && logicalPathNfc(source) === occurrence.logical_path + ? source + : occurrence.logical_path, + ); +}; + +const packageAncestor = ( + occurrence: InventoryOccurrence, + byId: Map, + formatOf: (occurrence: InventoryOccurrence) => string, +): InventoryOccurrence | undefined => { + const seen = new Set(); + let parentId = occurrence.parent_occurrence_id; + while (parentId !== null) { + if (seen.has(parentId)) return undefined; + seen.add(parentId); + const parent = byId.get(parentId); + if (parent === undefined) return undefined; + if (EXTRACTABLE.has(formatOf(parent))) return parent; + parentId = parent.parent_occurrence_id; + } + return undefined; +}; + +const hasExtractableAncestor = ( + occurrence: InventoryOccurrence, + byId: Map, + formatOf: (occurrence: InventoryOccurrence) => string, +): boolean => packageAncestor(occurrence, byId, formatOf) !== undefined; + +const entryPath = ( + occurrence: InventoryOccurrence, + parent: InventoryOccurrence, +): string => { + const logical = occurrence.logical_path; + const parentLogical = parent.logical_path; + if (parentLogical.length === 0 || parentLogical === ".") return logical; + const prefix = `${parentLogical}/`; + return logical.startsWith(prefix) ? logical.slice(prefix.length) : logical; +}; + +/** + * Readers do not follow links, so a map one names, or a map a script or page + * it names could hold, stays unread, not absent. The link is one omission + * however many declarations reach it. + */ +const noteUnfollowedMapLink = ( + state: ScanState, + kind: string, + logical: string, + location: string, +): void => { + if (kind !== "symlink") return; + if ( + !isSourceMap(logical, "file") && + !canDeclareMap({ kind: "file", logical }) + ) + return; + recordMemberCompletion(state, location, { map: true, script: true }); + state.skippedMaps += 1; + // A generic `.map` link may name a linker map, as an unread map may. + if (isSourceMap(logical, "file") && !isJavaScriptSourceMapName(logical)) + state.skippedCompanions += 1; +}; + +const noteUnlistedEntry = (state: ScanState, entry: ArtifactEntry): void => { + noteUnfollowedMapLink(state, entry.kind, entry.path, entry.path); + if (entry.kind !== "file") return; + if (entry.encrypted) state.unreadable += 1; + if (isSourceMap(entry.path, "file")) { + noteUnreadMap(state, namedMapReference(entry.path, entry.path), null); + state.skippedText += 1; + } else if (isText(entry.path, "file")) state.skippedText += 1; +}; + +const JAVASCRIPT_SOURCE_MAP = /\.(?:cjs|css|cts|js|jsx|mjs|mts|ts|tsx)\.map$/iu; + +const isJavaScriptSourceMapName = (logical: string): boolean => + JAVASCRIPT_SOURCE_MAP.test(logical); + +const isSourceMap = (logical: string, kind: string): boolean => + kind === "source-map" || logical.toLowerCase().endsWith(".map"); + +const isDeclaredText = (kind: string): boolean => + kind === "javascript" || + kind === "source-map" || + kind === "plist" || + kind === "entitlements"; + +const isText = (logical: string, kind: string): boolean => + isDeclaredText(kind) || TEXT_EXTENSION.test(logical); + +const hasCompanionName = (logical: string): boolean => { + const lower = logical.toLowerCase(); + return ( + lower.endsWith(".pdb") || + lower.endsWith(".debug") || + lower.endsWith(".dsym") + ); +}; + +/** + * FAT identity from the container owner. A prefix can only rule a universal + * image out against the member's real size: without one, or with slices past + * the prefix, the image may be native and stays counted as one. + */ +const sniffFatFormat = ( + bytes: Buffer, + size: number | null, +): "mach-o-universal" | null => { + const known = size !== null && size >= bytes.length; + const header = readFatContainerHeader(bytes, known ? size : bytes.length); + if (header.state !== "parsed") return null; + return known && + classifyFatContainerEvidence(header, bytes).state === "not-mach-o" + ? null + : "mach-o-universal"; +}; + +/** Header facts from the executable owners. A JVM class is not a FAT Mach-O. */ +const sniffNativeFormat = ( + bytes: Buffer, + size: number | null = bytes.length, +): NativeFormat | null => { + if ( + bytes.length >= 4 && + bytes[0] === 0x7f && + bytes[1] === 0x45 && + bytes[2] === 0x4c && + bytes[3] === 0x46 + ) + return "elf"; + if (readMachoHeaderPrefixFacts(bytes).format !== null) return "mach-o"; + const universal = sniffFatFormat(bytes, size); + if (universal !== null) return universal; + const windows = observeMzWindowsHeader(bytes); + if (windows.state === "declared" && windows.signature === "PE") return "pe"; + return null; +}; + +const targetKind = ( + path: string, + format: string, + directory: boolean, +): ReleaseExposureScan["target"]["kind"] => { + if (directory && basename(path).toLowerCase().endsWith(".app")) return "app"; + if (directory || format === "directory") return "directory"; + if (format === "asar") return "asar"; + if (NATIVE.has(format) || EXTRACTABLE.has(format)) return "executable"; + return "file"; +}; + +/** + * Wide text is valid UTF-8 when its characters are ASCII, so validity alone + * cannot separate it from UTF-8 holding a NUL. Density can: UTF-16 puts a NUL in + * every other byte of each ASCII character, and a stray NUL is one byte of many. + */ +const hasNonUtf8Nul = (bytes: Buffer): boolean => { + const header = bytes.subarray(0, MEMBER_ENCODING_HEADER_BYTES); + const nuls = header.reduce((count, byte) => count + (byte === 0 ? 1 : 0), 0); + return ( + nuls > 0 && + (nuls * WIDE_TEXT_NUL_DIVISOR >= header.length || !isUtf8(bytes)) + ); +}; + +const pushSample = (samples: string[], value: string): void => { + if ( + value.length === 0 || + samples.includes(value) || + samples.length >= MAX_RELEASE_EXPOSURE_SAMPLES + ) + return; + samples.push(value); +}; + +const pushLimitation = (state: ScanState, message: string): void => { + if ( + state.limitations.length >= LIMITATION_CAP || + state.limitations.includes(message) + ) + return; + state.limitations.push(message); +}; + +const fileSizeRecovery = (maxFileBytes: number) => + maxFileBytes >= MAX_RELEASE_EXPOSURE_FILE_BYTES + ? fixedAllowanceOptions( + `The release exposure input allowance of ${String(MAX_RELEASE_EXPOSURE_FILE_BYTES)} bytes (this release has no runtime override)`, + "input", + ) + : { + retryable: false as const, + remediationAction: `Raise max_file_bytes above ${String(maxFileBytes)} bytes or select a smaller artifact. The ceiling is ${String(MAX_RELEASE_EXPOSURE_FILE_BYTES)} bytes.`, + }; + +/** + * The header admission is its own bound, not the selected-file ceiling, so + * max_file_bytes cannot repair it. The recovery names the shared setting the + * composition threaded into this reader. + */ +const asarHeaderAdmissionFailure = ( + cause: ArtifactReaderFailure, + admission: ArtifactAdmissionLimit, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + OPERATION, + "capacity", + admission.requiredBytes === undefined + ? `An ASAR header/index exceeds ${String(admission.maximumBytes)} bytes.` + : `An ASAR header/index of ${String(admission.requiredBytes)} bytes exceeds ${String(admission.maximumBytes)} bytes.`, + { + asar_max_header_bytes: admission.maximumBytes, + ...(admission.requiredBytes === undefined + ? {} + : { required_header_bytes: admission.requiredBytes }), + }, + { + cause, + retryable: false, + remediationAction: asarHeaderAdmissionRecovery(admission), + limit: { + ...analysisSettingLimit( + "asar.max_header_bytes", + admission.maximumBytes, + admission.source, + ), + ...(admission.requiredBytes === undefined + ? {} + : { required_value: admission.requiredBytes }), + }, + }, + ); + +const releaseExposureLimitFailure = ( + cause: ArtifactReaderFailure, + maxFileBytes: number, +): AnalysisResourceConstraintError => + cause.admission === undefined + ? new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: maxFileBytes }, + { cause, ...fileSizeRecovery(maxFileBytes) }, + ) + : asarHeaderAdmissionFailure(cause, cause.admission); + +const releaseExposureFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + path: string, + maxFileBytes: number, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return releaseExposureLimitFailure(cause, maxFileBytes); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError(RELEASE_EXPOSURE_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const releaseExposureFailure = ( + cause: unknown, + path: string, + maxFileBytes: number, +): AnalysisError => + projectStableArtifactFailure( + cause, + releaseExposureFailurePrimary( + artifactFilePrimaryFacts(cause), + path, + maxFileBytes, + ), + path, + RELEASE_EXPOSURE_PROVIDER.id, + OPERATION, + ); diff --git a/src/artifacts/apple/BinaryPlistMaterializer.test.ts b/src/artifacts/apple/BinaryPlistMaterializer.test.ts index 65c60fe0e3..8395821bf2 100644 --- a/src/artifacts/apple/BinaryPlistMaterializer.test.ts +++ b/src/artifacts/apple/BinaryPlistMaterializer.test.ts @@ -1,10 +1,13 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { scanBinaryPlistScalars } from "./PlistScalars.js"; import { plistUidValue } from "../../domain/apple/plistUid.js"; -import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js"; +import { + decodeBinaryPlistText, + decodeBinaryPlistValue, +} from "./BinaryPlistMaterializer.js"; import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; import { parseXmlPropertyList } from "../../domain/propertyListKeys.js"; @@ -179,3 +182,121 @@ it("keeps uint64 wrapping for nine-byte offset and reference declarations", () = expect(decode(bytes)).toEqual([7]); expect(bytes.equals(original)).toBe(true); }); + +const dictionary = (...references: readonly number[]): Buffer => + Buffer.from([0xd0 | (references.length / 2), ...references]); +const budget = 1024 * 1024; + +it("reads a string no object references", () => { + const bytes = source([ + dictionary(1, 2), + text("key"), + text("kept"), + text("orphan"), + ]); + expect(decodeBinaryPlistText(bytes, budget).text).toContain("orphan"); +}); + +it("reads the value a later duplicate key replaced", () => { + const bytes = source([ + dictionary(1, 1, 2, 3), + text("key"), + text("replaced"), + text("final"), + ]); + expect(decode(bytes)).toEqual({ key: "final" }); + expect(decodeBinaryPlistText(bytes, budget).text).toContain("replaced"); +}); + +it("reads an unreferenced UTF-16BE string and data object", () => { + const wide = Buffer.concat([ + Buffer.from([0x62]), + Buffer.from("é!", "utf16le").swap16(), + ]); + const data = Buffer.concat([Buffer.from([0x44]), Buffer.from("blob")]); + const bytes = source([ + dictionary(1, 2), + text("key"), + text("kept"), + wide, + data, + ]); + const { text: decoded, complete } = decodeBinaryPlistText(bytes, budget); + expect(decoded).toContain("é!"); + expect(decoded).toContain("blob"); + expect(complete).toBe(true); +}); + +it("honors a byte order mark in a data object", () => { + const data = (payload: Buffer): Buffer => + Buffer.concat([Buffer.from([0x4f, 0x10, payload.length]), payload]); + const header = "-----BEGIN PRIVATE KEY-----"; + const little = Buffer.from(`\ufeff${header}`, "utf16le"); + const big = Buffer.from(little).swap16(); + for (const payload of [little, big]) + expect( + decodeBinaryPlistText( + source([dictionary(1, 2), text("key"), data(payload)]), + budget, + ), + ).toEqual({ text: expect.stringContaining(header), complete: true }); + const wider = Buffer.from([0xff, 0xfe, 0, 0, 0x6b, 0, 0, 0]); + expect( + decodeBinaryPlistText( + source([dictionary(1, 2), text("key"), data(wider)]), + budget, + ).complete, + ).toBe(false); +}); + +it("keeps readable text beside an unreferenced string it cannot read", () => { + const bytes = source([ + dictionary(1, 2), + text("key"), + text("kept"), + Buffer.from([0x5f, 0x10, 0xff]), + ]); + expect(decodeBinaryPlistText(bytes, budget)).toEqual({ + text: expect.stringContaining("kept"), + complete: false, + }); +}); + +it("bounds the text that overlapping table entries repeat", () => { + const entries = 200; + const headerBytes = 6; + const shared = Buffer.alloc(60_000 + entries * headerBytes, 0x61); + const root = Buffer.from([0xd1, 1, 2, 0x51, 0x6b, 0x51, 0x76]); + const offsets = [8, 11, 13]; + for (let entry = 0; entry < entries; entry += 1) { + const at = entry * headerBytes; + shared.set([0x4f, 0x12], at); + shared.writeUInt32BE(shared.length - at - headerBytes, at + 2); + offsets.push(8 + root.length + at); + } + const table = 8 + root.length + shared.length; + const bytes = Buffer.alloc(table + offsets.length * 4 + 32); + bytes.write("bplist00", "latin1"); + root.copy(bytes, 8); + shared.copy(bytes, 8 + root.length); + offsets.forEach((offset, entry) => + bytes.writeUInt32BE(offset, table + entry * 4), + ); + const trailer = bytes.length - 32; + bytes[trailer + 6] = 4; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(BigInt(offsets.length), trailer + 8); + bytes.writeBigUInt64BE(BigInt(table), trailer + 24); + const toString = vi.spyOn(Buffer.prototype, "toString"); + const decoded = decodeBinaryPlistText(bytes, 2 * budget); + const decodedBytes = toString.mock.contexts.reduce( + (total, buffer) => total + (buffer as Buffer).length, + 0, + ); + toString.mockRestore(); + expect(decoded.complete).toBe(false); + expect(decoded.text.length).toBeLessThanOrEqual(2 * budget + offsets.length); + expect(decoded.text).toContain("k"); + // A range past the remaining budget is rejected before it is decoded. + expect(decodedBytes).toBeLessThanOrEqual(2 * budget + bytes.length); +}); diff --git a/src/artifacts/apple/BinaryPlistMaterializer.ts b/src/artifacts/apple/BinaryPlistMaterializer.ts index d91f86e812..50f0310a9c 100644 --- a/src/artifacts/apple/BinaryPlistMaterializer.ts +++ b/src/artifacts/apple/BinaryPlistMaterializer.ts @@ -1,12 +1,17 @@ import { readBinaryPlistLayout } from "./BinaryPlistLayout.js"; import { parseBinary } from "plist"; import { markPlistDictionary } from "../../domain/apple/plistUid.js"; +import { byteOrderMarkEncoding } from "../../domain/textByteOrderMark.js"; import { + binaryPlistObjectEnd, decodeBinaryPlistScalars, scanBinaryPlistScalars, type BinaryPlistScalarMetadata, } from "./PlistScalars.js"; -import type { PlistDecodePreflight } from "./PropertyListDecodeBudget.js"; +import { + preflightPropertyListDecode, + type PlistDecodePreflight, +} from "./PropertyListDecodeBudget.js"; /** Build one array of source scalar references; upstream never constructs a dictionary. */ const scalarArrayInput = ( @@ -128,3 +133,94 @@ export const decodeBinaryPlistValue = ( } return root.value; }; + +/** The payload of one stored string or data object, or null for any other type. */ +const storedPlistPayload = ( + bytes: Buffer, + offset: number, + objectEnd: number, + referenceWidth: number, +): + | { readonly type: number; readonly payload: Buffer } + | "unreadable" + | null => { + const marker = bytes[offset]; + if (marker === undefined) return "unreadable"; + const type = marker >> 4; + if (type !== 4 && type !== 5 && type !== 6 && type !== 7) return null; + const end = binaryPlistObjectEnd(bytes, offset, objectEnd, referenceWidth); + if (end === undefined) return "unreadable"; + const start = + (marker & 0x0f) === 0x0f + ? offset + 2 + (1 << (bytes[offset + 1]! & 0x0f)) + : offset + 1; + return { type, payload: bytes.subarray(start, end) }; +}; + +/** The text a data payload holds, honoring a byte order mark; null when no reader scans it. */ +const storedDataText = (payload: Buffer): string | null => { + const encoding = byteOrderMarkEncoding(payload); + if (encoding === "utf-32") return null; + return encoding === null + ? payload.toString("utf8") + : new TextDecoder(encoding).decode(payload); +}; + +/** + * Decode a payload `storedPlistPayload` found; call only once it fits the + * budget. Null when its encoding cannot be scanned. + */ +const storedPlistText = (type: number, payload: Buffer): string | null => + type === 5 + ? payload.toString("latin1") + : type === 6 + ? Buffer.from(payload).swap16().toString("utf16le") + : type === 4 + ? storedDataText(payload) + : payload.toString("utf8"); + +/** The text a binary plist stores; `complete` is false when part of it went unread. */ +export interface BinaryPlistText { + readonly text: string; + readonly complete: boolean; +} + +/** + * Decode every string, dictionary key and data payload a binary plist stores. + * Its strings are stored per object as ASCII, UTF-16BE or UTF-8, so the raw + * bytes do not show them all. This reads the object table rather than the + * decoded value, because a value drops objects its root no longer references + * and entries a later duplicate key replaced, and those bytes still ship. + * A stored object it cannot read, or text past `maxBytes` from table entries + * that overlap, leaves the result incomplete and keeps the text already read. + * Throws on a plist that cannot be decoded within `maxBytes`. + */ +export const decodeBinaryPlistText = ( + bytes: Buffer, + maxBytes: number, +): BinaryPlistText => { + decodeBinaryPlistValue(bytes, preflightPropertyListDecode(bytes, maxBytes)); + const layout = readBinaryPlistLayout(bytes); + if (!layout.ok) throw new RangeError("binary plist trailer is truncated"); + const texts: string[] = []; + let decoded = 0; + let complete = true; + for (const offset of new Set(layout.value.objectOffsets())) { + const stored = storedPlistPayload( + bytes, + Number(offset), + layout.value.trailerOffset, + layout.value.referenceWidth, + ); + if (stored === null) continue; + if (stored === "unreadable" || decoded + stored.payload.length > maxBytes) { + complete = false; + continue; + } + decoded += stored.payload.length; + const text = storedPlistText(stored.type, stored.payload); + if (text === null) complete = false; + else texts.push(text); + } + return { text: texts.join("\n"), complete }; +}; diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index 2e880b4931..6d5778fdf1 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -31,6 +31,15 @@ export const inventoryArtifact = async ( }); }; +/** Inventory under an admission the caller already holds on its scope. */ +export const inventoryArtifactInScope = async ( + inputPath: string, + options: ArtifactInventoryOptions, +): Promise => + artifactInventoryResultSchema.parse( + await scanArtifactInventoryInScope(inputPath, options), + ); + export const scanArtifactInventoryInScope = async ( inputPath: string, options: ArtifactInventoryOptions, diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 8b932dd0ce..35af41535a 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -24,6 +24,9 @@ import { ArtifactEmbeddedFormatProbes, type ArtifactEmbeddedFormatProbeFacts, } from "../ArtifactEmbeddedFormatProbes.js"; +import type { BunStandaloneProbe } from "../bun/BunModuleGraphLayout.js"; +import type { NodeSeaLocation } from "../nodeSea/NodeSeaLocation.js"; +import type { PyInstallerCookie } from "../pyinstaller/CArchiveLayout.js"; import { assertStableRegularFileIdentity, RegularFileChangedError, @@ -235,6 +238,53 @@ export const classifyAndHashRootForInventory = async ( return outcome.value; }; +type DenoPresentProbe = Extract< + Awaited>, + { readonly status: "present" } +>; + +export type EmbeddedExecutablePackage = + | { readonly format: "deno-standalone"; readonly probe: DenoPresentProbe } + | { readonly format: "node-sea"; readonly location: NodeSeaLocation } + | { + readonly format: "bun-standalone"; + readonly probe: Extract< + BunStandaloneProbe, + { readonly status: "present" } + >; + } + | { readonly format: "dotnet-bundle"; readonly headerOffset: number } + | { readonly format: "pyinstaller"; readonly cookie: PyInstallerCookie }; + +/** + * Executable-branch package order for a native image. + * Root classification and release-exposure probing both use this decision. + */ +export const classifyEmbeddedExecutablePackage = async ( + probes: ArtifactEmbeddedFormatProbes, + nativeFormat: string, + signal?: AbortSignal, +): Promise => { + const sea = await probes.nodeSea(); + abortIfNeeded(signal); + if (sea !== undefined) return { format: "node-sea", location: sea }; + const bun = await probes.bunStandalone(); + if (bun.status === "present") return { format: "bun-standalone", probe: bun }; + const deno = await probes.denoStandalone(); + if (deno.status === "present") + return { format: "deno-standalone", probe: deno }; + const packaged = await probes.pyInstaller(); + abortIfNeeded(signal); + if (nativeFormat !== "mach-o-universal") { + const headerOffset = await probes.dotnetBundle(); + if (headerOffset !== undefined) + return { format: "dotnet-bundle", headerOffset }; + } + if (packaged !== undefined) + return { format: "pyinstaller", cookie: packaged }; + return undefined; +}; + const classifyRootFormat = async ( path: string, source: RootInventorySource, @@ -374,42 +424,47 @@ const classifyRootFormat = async ( ) return zipRootPackageFormat(lower); if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) { - const sea = await embeddedProbes.nodeSea(); - abortIfNeeded(signal); - if (sea !== undefined) { + const embedded = await classifyEmbeddedExecutablePackage( + embeddedProbes, + format, + signal, + ); + if (embedded?.format === "node-sea") { const decoded = await decodeNodeSeaBlob( - await readNodeSeaBlob(source, sea, signal, readerLimits.node_sea), + await readNodeSeaBlob( + source, + embedded.location, + signal, + readerLimits.node_sea, + ), signal, readerLimits.node_sea, ); return { kind: "node-sea", source, - location: sea, + location: embedded.location, decoded, limits: readerLimits.node_sea, }; } - const bun = await embeddedProbes.bunStandalone(); - if (bun.status === "present") - return { kind: "bun-standalone", source, probe: bun }; - const deno = await embeddedProbes.denoStandalone(); - if (deno.status === "present") - return { kind: "deno-standalone", source, probe: deno }; - const packaged = await embeddedProbes.pyInstaller(); - abortIfNeeded(signal); - if (format !== "mach-o-universal") { - const headerOffset = await embeddedProbes.dotnetBundle(); - if (headerOffset !== undefined) - return { kind: "dotnet-bundle", source, headerOffset }; - } + if (embedded?.format === "bun-standalone") + return { kind: "bun-standalone", source, probe: embedded.probe }; + if (embedded?.format === "deno-standalone") + return { kind: "deno-standalone", source, probe: embedded.probe }; + if (embedded?.format === "dotnet-bundle") + return { + kind: "dotnet-bundle", + source, + headerOffset: embedded.headerOffset, + }; // A valid cookie also proves enough bytes for a thin Mach-O identity; // PE and universal Mach-O classification already require stronger proof. - if (packaged !== undefined) + if (embedded?.format === "pyinstaller") return { kind: "pyinstaller", source, - cookie: packaged, + cookie: embedded.cookie, carrierPathDialect: format === "pe" ? "windows" : "posix", }; } diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 75a39c8efe..5396d669c3 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -170,6 +170,7 @@ const scanCanonical = async ( options.signal, options.integrity, options.asarHeaderAdmission, + options.acquisitionLimits, ); outcome = { kind: "completed", diff --git a/src/artifacts/inventory/scanReader.test.ts b/src/artifacts/inventory/scanReader.test.ts index a808a2758e..59af6373bd 100644 --- a/src/artifacts/inventory/scanReader.test.ts +++ b/src/artifacts/inventory/scanReader.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { Readable } from "node:stream"; import { ArtifactEntryContentUnavailable, ArtifactReaderFailure, @@ -7,6 +8,70 @@ import { } from "../ArtifactReader.js"; import { scanReader } from "./scanReader.js"; +it.each(["entries", "declared bytes", "unknown bytes", "understated bytes"])( + "bounds inventory acquisition by %s and retains completed members", + async (scenario) => { + let opened = 0; + let chunks = 0; + let returned = false; + const reader: ArtifactReader = { + format: "asar", + async *entries() { + try { + yield entry; + yield { + ...entry, + path: "large", + declaredSize: + scenario === "unknown bytes" + ? null + : scenario === "declared bytes" + ? 10 + : 1, + }; + throw new Error("traversal must stop before the next entry"); + } finally { + returned = true; + } + }, + async open(member) { + opened += 1; + if (member.path === "member") return Readable.from([Buffer.from("x")]); + return Readable.from( + (async function* () { + for (let index = 0; index < 100; index += 1) { + chunks += 1; + yield Buffer.alloc(2); + } + })(), + { highWaterMark: 1 }, + ); + }, + provenance: () => [], + async close() {}, + }; + const result = await scanReader( + reader, + [reader], + undefined, + "fail", + undefined, + { + maximumEntries: scenario === "entries" ? 1 : 10, + maximumBytes: 2, + }, + ); + expect(result.occurrences).toHaveLength(1); + expect(result.occurrences[0]?.hash_status).toBe("verified"); + expect(result.traversal.status).toBe("unknown"); + expect(opened).toBe( + scenario === "entries" || scenario === "declared bytes" ? 1 : 2, + ); + expect(chunks).toBeLessThanOrEqual(2); + expect(returned).toBe(true); + }, +); + const entry: ArtifactEntry = { path: "member", kind: "file", diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index a4f8f2c549..53c0ad680d 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -22,7 +22,12 @@ import { type ArtifactReaderTraversal, } from "../ArtifactReader.js"; import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js"; -import { DirectoryArtifactEntryAccessDenied } from "../DirectoryArtifactReader.js"; +import { + DirectoryArtifactEntryAccessDenied, + DirectoryArtifactReader, + DirectoryArtifactTraversalLimit, +} from "../DirectoryArtifactReader.js"; +import type { ArtifactInventoryOptions } from "./types.js"; import { AsarArtifactReader } from "../AsarArtifactReader.js"; import { InesArtifactReader, @@ -57,6 +62,9 @@ const emptyScan = (): { } => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] }); interface ScanContext { + readonly acquisitionLimits: ArtifactInventoryOptions["acquisitionLimits"]; + entriesExamined: number; + bytesRead: number; readonly reader: ArtifactReader; readonly signal: AbortSignal | undefined; readonly integrity: ArtifactIntegrityPolicyName; @@ -84,6 +92,7 @@ export const scanReader = async ( signal?: AbortSignal, integrity: ArtifactIntegrityPolicyName = "fail", asarHeaderAdmission?: AsarHeaderAdmission, + acquisitionLimits?: ArtifactInventoryOptions["acquisitionLimits"], ): Promise<{ readonly nodes: Map; readonly occurrences: MutableOccurrence[]; @@ -111,6 +120,9 @@ export const scanReader = async ( rootContradiction: undefined, }; const context: ScanContext = { + acquisitionLimits, + entriesExamined: 0, + bytesRead: 0, reader, signal, integrity, @@ -175,6 +187,15 @@ const containerContradiction = ( return pair; }; +class InventoryAcquisitionLimit extends ArtifactReaderFailure { + constructor() { + super( + "limit", + "Artifact inventory acquisition allowance exhausted; member coverage is incomplete.", + ); + } +} + const inventoryEntries = ( context: ScanContext, reader: ArtifactReader, @@ -183,6 +204,14 @@ const inventoryEntries = ( reader .entries(context.signal, { onCoverage: context.traversal.onCoverage, + ...(reader instanceof DirectoryArtifactReader && + context.acquisitionLimits !== undefined + ? { + maximumEntries: + context.acquisitionLimits.maximumEntries - + context.entriesExamined, + } + : {}), onOmission: (omission) => context.traversal.onOmission({ ...omission, @@ -221,6 +250,12 @@ const visitArtifactEntries = async ( continue; } const entry = next.value; + if ( + context.acquisitionLimits !== undefined && + context.entriesExamined >= context.acquisitionLimits.maximumEntries + ) + throw new InventoryAcquisitionLimit(); + context.entriesExamined += 1; const logicalPath = normalizeArtifactPath( frame.prefix.length === 0 ? entry.path @@ -330,7 +365,15 @@ const visitArtifactEntries = async ( } } } catch (cause: unknown) { - failure = { cause }; + if ( + cause instanceof InventoryAcquisitionLimit || + cause instanceof DirectoryArtifactTraversalLimit + ) + context.traversal.onCoverage({ + status: "unknown", + limitations: [cause.message], + }); + else failure = { cause }; } finally { // Readers remain owned by the inventory until its complete observation is built. for (const { iterator, prefix, reader } of stack.reverse()) { @@ -368,6 +411,13 @@ const digestArtifactEntry = async ( > => { if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) return undefined; + if ( + context.acquisitionLimits !== undefined && + entry.declaredSize !== null && + entry.declaredSize > + context.acquisitionLimits.maximumBytes - context.bytesRead + ) + throw new InventoryAcquisitionLimit(); const reconstruction = currentReader === context.reader ? context.reconstruction : undefined; if (reconstruction !== undefined && entry.byteOffset !== reconstruction.bytes) @@ -377,12 +427,19 @@ const digestArtifactEntry = async ( context.signal, entry.declaredIntegrity?.algorithm, context.asarHeaderAdmission, - reconstruction === undefined - ? undefined - : (chunk) => { - reconstruction.hash.update(chunk); - reconstruction.bytes += chunk.length; - }, + (chunk) => { + if ( + context.acquisitionLimits !== undefined && + chunk.length > + context.acquisitionLimits.maximumBytes - context.bytesRead + ) + throw new InventoryAcquisitionLimit(); + context.bytesRead += chunk.length; + if (reconstruction !== undefined) { + reconstruction.hash.update(chunk); + reconstruction.bytes += chunk.length; + } + }, ); reconstruction?.regionOrder.push(entry.path); const claim = entry.declaredIntegrity; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index 5938f64404..d8fd4653c3 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -6,6 +6,13 @@ import type { ArtifactReaderLimits } from "../../domain/artifactReaderLimits.js" /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { + /** Optional acquisition bounds; exhaustion retains a partial inventory. */ + readonly acquisitionLimits?: + | { + readonly maximumEntries: number; + readonly maximumBytes: number; + } + | undefined; readonly readerLimits?: ArtifactReaderLimits | undefined; readonly resourceScope: ArtifactResourceScope; /** Identity of a previously admitted file root; directory roots have no file digest. */ diff --git a/src/cli.ts b/src/cli.ts index c369c4e5cc..b288cefc65 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js"; import { createDirectAnalysis } from "./composition/directAnalysis.js"; import { registerEvmCommands } from "./cli/evmCommands.js"; import { registerDesktopApplicationCommands } from "./cli/desktopApplicationCommands.js"; +import { registerReleaseExposureCommands } from "./cli/releaseExposureCommands.js"; import { registerGoCommands } from "./cli/goCommands.js"; import { registerModelCommands } from "./cli/modelCommands.js"; import { registerIl2cppCommands } from "./cli/il2cppCommands.js"; @@ -195,6 +196,7 @@ export const createCli = ( registerSignalCommands(cli, logger, environment); registerEvmCommands(cli, logger, environment); registerDesktopApplicationCommands(cli, logger); + registerReleaseExposureCommands(cli, logger, environment); registerGoCommands(cli, logger); registerIl2cppCommands(cli, logger); registerModelCommands(cli, logger); diff --git a/src/cli/releaseExposureCommands.ts b/src/cli/releaseExposureCommands.ts new file mode 100644 index 0000000000..22382612b6 --- /dev/null +++ b/src/cli/releaseExposureCommands.ts @@ -0,0 +1,87 @@ +import { z } from "incur"; +import type { Logger } from "pino"; + +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { createReleaseExposureService } from "../composition/releaseExposure.js"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { + auditReleaseExposureInputSchema, + exposureMeetsFailOn, + releaseExposureReportSchema, +} from "../domain/releaseExposure.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; +import { cliDecimalField } from "./options.js"; +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import type { CliInstance } from "./types.js"; + +/** Report what static analysis recovers from one shipped artifact. */ +export const registerReleaseExposureCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly>, +): void => { + cli.command(CLI_COMMANDS.auditReleaseExposure, { + description: + "Report what static analysis of a shipped artifact recovers. Reads the selected path. When the selection is a file, the parent directory is listed for sibling .pdb, .dSYM, .debug, and .map debug companions.", + args: z.object({ + path: localPathStringSchema.describe( + "Shipped .app bundle, install directory, ASAR, or executable", + ), + }), + options: z.object({ + fail_on: z + .enum(["high", "medium", "low"]) + .optional() + .describe( + "Completed audit exits 3 when an exposed check meets this severity. Unknown and unassessed checks do not.", + ), + maxFileBytes: cliDecimalField( + auditReleaseExposureInputSchema.shape.max_file_bytes, + ), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const service = createReleaseExposureService(environment); + return await logCliCommand( + logger, + CLI_COMMANDS.auditReleaseExposure, + async () => { + const projected = await finishCliEvidenceCommand( + args.path, + () => + service.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + }, + { signal }, + ), + service, + "rea-release-exposure", + "audit_release_exposure", + signal, + ).then(projectCliAnalysisResult); + if ( + options.fail_on !== undefined && + projected !== null && + typeof projected === "object" && + "normalized_result" in projected + ) { + const report = releaseExposureReportSchema.safeParse( + projected.normalized_result, + ); + if ( + report.success && + exposureMeetsFailOn(report.data, options.fail_on) + ) + process.exitCode = 3; + } + return projected; + }, + ); + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 83e959fd5b..892978fa7b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -66,6 +66,7 @@ export const CLI_COMMANDS = Object.freeze({ debugWithRizin: "debug-with-rizin", inspectDesktopApplication: "inspect-desktop-application", + auditReleaseExposure: "audit-release-exposure", inspectGoBinary: "inspect-go-binary", inspectIl2cppMetadata: "inspect-il2cpp-metadata", inspectModelArtifact: "inspect-model-artifact", diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index 441d6d1842..58462294b8 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -45,6 +45,7 @@ import { PE_IMPORTS_PROVIDER, PE_LOAD_CONFIG_PROVIDER, PE_RESOURCES_PROVIDER, + RELEASE_EXPOSURE_PROVIDER, WEB_SCRIPT_EXPORT_PROVIDER, WINDOWS_SHORTCUT_PROVIDER, } from "../domain/investigationProviders.js"; @@ -479,6 +480,13 @@ export const CAPABILITY_DECLARATIONS = { }), ], }, + releaseExposure: { + providers: [ + providerBinding(RELEASE_EXPOSURE_PROVIDER, { + group: "RELEASE_EXPOSURE_TOOL_CONTRACTS", + }), + ], + }, v8CodeCache: { providers: [ providerBinding(V8_CODE_CACHE_PROVIDER_IDENTITY, { diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 3cd952c5a2..ae5e5cd80d 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -34,6 +34,7 @@ import { createV8CodeCacheService } from "./v8CodeCache.js"; import { createModelArtifactService } from "./model.js"; import { createIl2cppMetadataService } from "./il2cpp.js"; import { createElectronFuseService } from "./electronFuses.js"; +import { createReleaseExposureService } from "./releaseExposure.js"; import { createPythonPycService } from "./pythonPyc.js"; import { createWebNetworkCaptureService } from "./webNetworkCaptures.js"; import { createWebSourceLocationService } from "./webSourceLocations.js"; @@ -380,6 +381,9 @@ const modules = { electronFuses: service( ({ options }) => options.electronFuses ?? createElectronFuseService(), ), + releaseExposure: service(({ environment, settings }) => + createReleaseExposureService(environment, settings), + ), pythonPyc: service( ({ options }) => options.pythonPyc ?? createPythonPycService(), ), diff --git a/src/composition/releaseExposure.ts b/src/composition/releaseExposure.ts new file mode 100644 index 0000000000..1f069d72e3 --- /dev/null +++ b/src/composition/releaseExposure.ts @@ -0,0 +1,27 @@ +import type { AnalysisSettingsReader } from "../domain/analysisSettings.js"; +import { runtimeAsarHeaderAdmission } from "../config/runtimeAnalysisSettings.js"; +import { ReleaseExposureReader } from "../artifacts/ReleaseExposureReader.js"; +import { ReleaseExposureService } from "../application/releaseExposure/ReleaseExposureService.js"; +import { createDesktopApplicationService } from "./desktop.js"; +import { createElectronFuseService } from "./electronFuses.js"; +import { createGoBinaryService } from "./go.js"; +import { createRustBinaryService } from "./rust.js"; + +/** + * Compose the read-only release-exposure audit. The reader admits ASAR + * headers under the shared REA_ASAR_MAX_HEADER_BYTES and runtime + * asar.max_header_bytes admission, like every other ASAR owner. + */ +export const createReleaseExposureService = ( + environment: Readonly> = process.env, + settings?: AnalysisSettingsReader, +): ReleaseExposureService => + new ReleaseExposureService({ + reader: new ReleaseExposureReader( + runtimeAsarHeaderAdmission(environment, settings), + ), + electronFuses: createElectronFuseService(), + desktop: createDesktopApplicationService(), + goBinaries: createGoBinaryService(), + rustBinaries: createRustBinaryService(), + }); diff --git a/src/contracts/releaseExposureToolContracts.ts b/src/contracts/releaseExposureToolContracts.ts new file mode 100644 index 0000000000..af094a31ef --- /dev/null +++ b/src/contracts/releaseExposureToolContracts.ts @@ -0,0 +1,26 @@ +import { + auditReleaseExposureInputSchema, + releaseExposureReportSchema, +} from "../domain/releaseExposure.js"; +import type { ToolContract } from "./toolContractTypes.js"; +import { toolContractMetadata } from "./toolEffects.js"; +import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js"; + +/** Read-only report of what static analysis recovers from one shipped artifact. */ +export const RELEASE_EXPOSURE_TOOL_CONTRACTS = [ + { + name: "audit_release_exposure", + ...toolContractMetadata("audit_release_exposure"), + kind: "artifact-provider", + description: + "Report what static analysis of a shipped application artifact recovers. Accepts one local .app bundle, installation directory, ASAR, or executable. Each check has a stable id, a status, the observation behind it, and fixed remediation text. Unknown and unassessed checks stay in the report. This does not establish that an application is secure. Reads the selected artifact. When the selection is a file, the parent directory is listed for sibling .pdb, .dSYM, .debug, and .map debug companions.", + inputSchema: auditReleaseExposureInputSchema, + outputSchema: evidenceResultOf(releaseExposureReportSchema), + examples: [ + { + title: "Audit a shipped application", + input: { path: "/artifacts/MyApp.app" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 3a7b6608da..73a962fbc8 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -19,6 +19,7 @@ import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; +import { RELEASE_EXPOSURE_TOOL_CONTRACTS } from "./releaseExposureToolContracts.js"; import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js"; import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js"; import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js"; @@ -55,6 +56,7 @@ export const TOOL_CONTRACTS = [ ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ANALYSIS_VIEW_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, + ...RELEASE_EXPOSURE_TOOL_CONTRACTS, ...DESKTOP_APPLICATION_TOOL_CONTRACTS, ...EVM_TOOL_CONTRACTS, ...GO_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 11946c4772..382a873757 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -734,6 +734,11 @@ const TOOL_EFFECT_AUDIT: Readonly> = { launchesProcess: true, }), inspect_electron_fuses: evidence, + audit_release_exposure: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + }), export_web_scripts: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/contracts/toolFamilies.ts b/src/contracts/toolFamilies.ts index 234bbb41dc..4d4bec52e7 100644 --- a/src/contracts/toolFamilies.ts +++ b/src/contracts/toolFamilies.ts @@ -16,6 +16,7 @@ import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "./reverseEngineeringToolCont import { GDB_TOOL_CONTRACTS } from "./gdbToolContracts.js"; import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; +import { RELEASE_EXPOSURE_TOOL_CONTRACTS } from "./releaseExposureToolContracts.js"; import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolContracts.js"; import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; import { V8_CODE_CACHE_TOOL_CONTRACTS } from "./v8/v8CodeCacheToolContracts.js"; @@ -90,6 +91,7 @@ export const TOOL_CONTRACT_GROUPS = { OFFICIAL_TOOL_CONTRACTS, PCAP_TOOL_CONTRACTS, PYTHON_PYC_TOOL_CONTRACTS, + RELEASE_EXPOSURE_TOOL_CONTRACTS, REVERSE_ENGINEERING_TOOL_CONTRACTS, RIZIN_DEBUG_TOOL_CONTRACTS, RUST_TOOL_CONTRACTS, @@ -145,6 +147,7 @@ export const TOOL_FAMILY_DEFINITIONS = [ surface: "artifact-provider", contracts: [ ...TOOL_CONTRACT_GROUPS.ARTIFACT_TOOL_CONTRACTS, + ...TOOL_CONTRACT_GROUPS.RELEASE_EXPOSURE_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.DESKTOP_APPLICATION_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.EVM_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.GO_TOOL_CONTRACTS, diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index b8cb942f81..09714bf3e9 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -10,14 +10,20 @@ export class AnalysisUnsupportedTargetError extends AnalysisError { readonly _tag = "AnalysisUnsupportedTargetError"; /** Refusal-specific recovery, when the producer knows an available workflow. */ readonly remediationAction: string | undefined; + /** The target holds the content, so unsupported does not make it inapplicable. */ + readonly undecodedContent: boolean; constructor( readonly operation: string, readonly path: string, readonly reason: string, - options?: AnalysisErrorOptions & { readonly remediationAction?: string }, + options?: AnalysisErrorOptions & { + readonly remediationAction?: string; + readonly undecodedContent?: boolean; + }, ) { super(`Unsupported target for ${operation} at ${path}: ${reason}`, options); this.remediationAction = options?.remediationAction; + this.undecodedContent = options?.undecodedContent ?? false; } } diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index a1723e9d71..ec369a7e58 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -733,27 +733,61 @@ export const parsePeCodeViewFromBuffer = ( fileBytes: Buffer, peOffset: number, ): PeCodeViewRecord | undefined => { - if (fileBytes.length < peOffset + 24) return undefined; + const result = inspectPeCodeViewFromBuffer(fileBytes, peOffset); + return result.ok ? result.value : undefined; +}; + +/** Distinguish absent CodeView records from incomplete bounded parsing. */ +export const inspectPeCodeViewFromBuffer = ( + fileBytes: Buffer, + peOffset: number, +): Result< + PeCodeViewRecord | undefined, + { readonly kind: "incomplete"; readonly message: string } +> => { + const incomplete = (message: string) => + err({ kind: "incomplete" as const, message }); + if (fileBytes.length < peOffset + 24) + return incomplete("Truncated PE header."); const coff = readPeCoffHeader( fileBytes.subarray(peOffset + 4, peOffset + 24), ); - if (!coff.ok) return undefined; + if (!coff.ok) return incomplete(coff.error.message); const { sectionCount: numberOfSections, optionalHeaderSize } = coff.value; if ( optionalHeaderSize > 4096 || fileBytes.length < peOffset + 24 + optionalHeaderSize ) - return undefined; + return incomplete( + "PE optional header is truncated or exceeds the parsing allowance.", + ); const optionalHeader = fileBytes.subarray( peOffset + 24, peOffset + 24 + optionalHeaderSize, ); - const debugDir = peDebugDirectoryEntry(optionalHeader); - if (debugDir === undefined) return undefined; + const fixed = readPeOptionalHeaderFixedFields(optionalHeader); + if (!fixed.ok) return incomplete(fixed.error.message); + const observed = readPeOptionalHeaderDirectory( + optionalHeader, + fixed.value, + 6, + ); + if (observed.state === "absent") return ok(undefined); + if (observed.state !== "present") + return incomplete("Truncated PE debug directory declaration."); + const { address: debugRva, size: debugSize } = observed.directory; + if (debugRva === 0 && debugSize === 0) return ok(undefined); + if (debugRva === 0 || debugSize < 28 || debugSize % 28 !== 0) + return incomplete("Invalid PE debug directory declaration."); + const debugDir = { + debugRva, + debugSize, + sizeOfHeaders: fixed.value.sizeOfHeaders, + }; const sectionTableOffset = peOffset + 24 + optionalHeaderSize; const sectionTableSize = numberOfSections * 40; if (fileBytes.length < sectionTableOffset + sectionTableSize) - return undefined; + return incomplete("Truncated PE section table."); const sectionTable = fileBytes.subarray( sectionTableOffset, sectionTableOffset + sectionTableSize, @@ -765,17 +799,27 @@ export const parsePeCodeViewFromBuffer = ( debugDir.sizeOfHeaders, Math.min(debugDir.debugSize, 4096), ); - if (debugFileOffset === undefined || fileBytes.length < debugFileOffset + 28) - return undefined; + if ( + debugFileOffset === undefined || + fileBytes.length < debugFileOffset + debugDir.debugSize + ) + return incomplete("PE debug directory is unmapped or truncated."); const maxDebugBytes = Math.min( debugDir.debugSize, fileBytes.length - debugFileOffset, ); + const directory = fileBytes.subarray( + debugFileOffset, + debugFileOffset + maxDebugBytes, + ); + let codeViewEntries = 0; + for (let offset = 0; offset < directory.length; offset += 28) + if (directory.readUInt32LE(offset + 12) === 2) codeViewEntries += 1; for (const { fileOffset: pointerToRawData, declaredSize: sizeOfData, } of peCodeViewDataRanges( - fileBytes.subarray(debugFileOffset, debugFileOffset + maxDebugBytes), + directory, sectionTable, numberOfSections, debugDir.sizeOfHeaders, @@ -787,9 +831,11 @@ export const parsePeCodeViewFromBuffer = ( Math.min(fileBytes.length, pointerToRawData + sizeOfData), ); const parsed = parseCodeViewData(codeViewData, sizeOfData); - if (parsed !== undefined) return parsed; + if (parsed !== undefined) return ok(parsed); } - return undefined; + return codeViewEntries > 0 + ? incomplete("PE CodeView records could not be decoded completely.") + : ok(undefined); }; /** CPU family a Mach-O cpu type names, or undefined when it names none here. */ diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index 3de2e826ef..1cf91a6d5d 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -212,6 +212,13 @@ export const PE_LOAD_CONFIG_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for the read-only release exposure audit. */ +export const RELEASE_EXPOSURE_PROVIDER = Object.freeze({ + id: "rea-release-exposure", + name: "REA release exposure audit", + version: "1", +} as const); + /** Provider identity for portable ELF, PE, and Mach-O hardening inspection. */ export const BINARY_HARDENING_PROVIDER = Object.freeze({ id: "rea-binary-hardening", diff --git a/src/domain/javascript/htmlArtifactReferences.test.ts b/src/domain/javascript/htmlArtifactReferences.test.ts index e1e5b44d8e..ca5f9ddecd 100644 --- a/src/domain/javascript/htmlArtifactReferences.test.ts +++ b/src/domain/javascript/htmlArtifactReferences.test.ts @@ -161,6 +161,7 @@ it("shares precise body coordinates and location limits across HTML and SVG", () ).toEqual([ { scriptType: "classic", + text: body, location: { start: { line: 2, column: 30 }, end: { line: 4, column: 0 } }, }, ]); @@ -182,6 +183,7 @@ it("retains exact inline script body coordinates, including unclosed scripts", ( { scriptType: "classic", location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } }, + text: body, }, ]); const unclosed = `${prefix}${body}`; @@ -189,6 +191,7 @@ it("retains exact inline script body coordinates, including unclosed scripts", ( { scriptType: "classic", location: { start: { line: 2, column: 8 }, end: { line: 4, column: 0 } }, + text: body, }, ]); }); @@ -206,6 +209,7 @@ it("counts every inline body but retains bounded locations", () => { start: { line: 1, column }, end: { line: 1, column: column + 1 }, }, + text: "x", }); }); diff --git a/src/domain/javascript/htmlArtifactReferences.ts b/src/domain/javascript/htmlArtifactReferences.ts index 29635482c0..7c8fd36a29 100644 --- a/src/domain/javascript/htmlArtifactReferences.ts +++ b/src/domain/javascript/htmlArtifactReferences.ts @@ -22,6 +22,8 @@ export interface HtmlInlineScript { readonly scriptType: "classic" | "module"; /** The body's range, between the opening and closing tags. */ readonly location: JavaScriptSourceRange; + /** The body as the HTML parser decoded it. */ + readonly text: string; } /** @@ -157,7 +159,7 @@ export const htmlArtifactReferences = ( if (inline !== undefined && scriptType !== null) { inlineScriptCount += 1; if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT) - inlineScripts.push({ scriptType, location: inline }); + inlineScripts.push({ scriptType, ...inline }); } } } @@ -173,22 +175,27 @@ export const htmlArtifactReferences = ( /** Locate a script element's non-blank body; browsers ignore it when src is set. */ const inlineScriptBody = ( node: DefaultTreeAdapterTypes.Element, -): JavaScriptSourceRange | undefined => { +): Pick | undefined => { const startTag = node.sourceCodeLocation?.startTag; if (startTag === undefined) return undefined; let blank = true; + let text = ""; let end: DefaultTreeAdapterTypes.TextNode["sourceCodeLocation"]; for (const child of node.childNodes) { if (!defaultTreeAdapter.isTextNode(child)) continue; if (blank && /\S/u.test(child.value)) blank = false; + text += child.value; end = child.sourceCodeLocation ?? end; } // An unclosed script's element range ends at its opening tag, so the body's // own text location bounds it. if (blank || end == null) return undefined; return { - start: sourcePoint(startTag.endLine, startTag.endCol), - end: sourcePoint(end.endLine, end.endCol), + location: { + start: sourcePoint(startTag.endLine, startTag.endCol), + end: sourcePoint(end.endLine, end.endCol), + }, + text, }; }; diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts index c36f9d4099..ab769cf200 100644 --- a/src/domain/nativeConvention.ts +++ b/src/domain/nativeConvention.ts @@ -114,6 +114,7 @@ export const desktopPathConventions = (path: string, rootName?: string) => { electronArchive: folded.at(-2) === "resources" && (name === "app.asar" || name === "default_app.asar"), + electronFramework: folded.includes("electron framework.framework"), unrealContainer: (name.endsWith(".pak") || name.endsWith(".utoc")) && folded.some( diff --git a/src/domain/propertyListXmlText.ts b/src/domain/propertyListXmlText.ts index 0cbccfede4..3cae8c3b69 100644 --- a/src/domain/propertyListXmlText.ts +++ b/src/domain/propertyListXmlText.ts @@ -1,3 +1,5 @@ +import { byteOrderMarkEncoding } from "./textByteOrderMark.js"; + export class XmlPlistTextDecodeError extends TypeError { constructor( readonly reason: @@ -15,24 +17,14 @@ export class XmlPlistTextDecodeError extends TypeError { /** Decode BOM-aware XML plist text and verify any declaration against its bytes. */ export const decodeXmlPlistText = (bytes: Uint8Array): string => { - if ( - (bytes[0] === 0xff && - bytes[1] === 0xfe && - bytes[2] === 0 && - bytes[3] === 0) || - (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 0xfe && bytes[3] === 0xff) - ) + const marked = byteOrderMarkEncoding(bytes); + if (marked === "utf-32") throw new XmlPlistTextDecodeError( "unsupported-encoding", "Unsupported XML encoding: UTF-32. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.", "UTF-32", ); - const encoding = - bytes[0] === 0xff && bytes[1] === 0xfe - ? "utf-16le" - : bytes[0] === 0xfe && bytes[1] === 0xff - ? "utf-16be" - : "utf-8"; + const encoding = marked ?? "utf-8"; // An ASCII declaration can identify an unsupported legacy encoding before // its body is incorrectly subjected to UTF-8 validation. const prolog = encoding === "utf-8" ? asciiXmlDeclaration(bytes) : undefined; diff --git a/src/domain/releaseExposure.test.ts b/src/domain/releaseExposure.test.ts new file mode 100644 index 0000000000..f4cfbf56e0 --- /dev/null +++ b/src/domain/releaseExposure.test.ts @@ -0,0 +1,807 @@ +import { describe, expect, it } from "vitest"; + +import { + assembleReleaseExposureReport, + exposureMeetsFailOn, + PACKAGED_NATIVE_IMAGES, + RELEASE_EXPOSURE_CHECK_IDS, + RELEASE_EXPOSURE_LIMITATION, + type ReleaseExposureOwnerFacts, + type ReleaseExposureScan, +} from "./releaseExposure.js"; + +const SHA = "ab".repeat(32); + +const scan = ( + overrides: Partial = {}, +): ReleaseExposureScan => ({ + target: { + selected_path: "/work/app", + kind: "directory", + format: "directory", + sha256: null, + }, + subject: { path: "/work/app", sha256: SHA, format: "directory" }, + native_images: [], + fuse_candidates: [], + source_maps: { + embedded: 0, + without_sources: 0, + unparsed: 0, + skipped: 0, + samples: [], + }, + extractable: { + formats: [], + readable_members: 0, + unreadable_members: 0, + samples: [], + }, + debug_companions: { paths: [], skipped: 0 }, + pdb: { examined: 0, paths: [], failed: 0 }, + text_members: { scanned: 1, skipped: 0 }, + coverage: "complete", + omitted: [], + limitations: [], + ...overrides, +}); + +const owners = ( + overrides: Partial = {}, +): ReleaseExposureOwnerFacts => ({ + electron_family: "not_electron", + fuse_images: [], + go_images: [], + rust_images: [], + credential_markers: { hits: [], omitted_hits: 0 }, + ...overrides, +}); + +const reportFor = ( + scanned: ReleaseExposureScan = scan(), + ownerFacts: ReleaseExposureOwnerFacts = owners(), +) => assembleReleaseExposureReport(scanned, ownerFacts); + +const statusOf = ( + report: ReturnType, + id: (typeof RELEASE_EXPOSURE_CHECK_IDS)[number], +) => report.checks.find((check) => check.id === id)?.status; + +describe("release exposure checks", () => { + it("keeps the fixed check order and the security limitation", () => { + const report = reportFor(); + expect(report.checks.map((check) => check.id)).toEqual([ + ...RELEASE_EXPOSURE_CHECK_IDS, + ]); + expect(report.limitations[0]).toBe(RELEASE_EXPOSURE_LIMITATION); + expect(report.summary.no_exposure_observed_in_assessed_checks).toBe(true); + expect(report.summary.by_status.not_assessed).toBe(3); + expect(report.summary.highest_exposed_severity).toBeNull(); + expect(statusOf(report, "native.hardening")).toBe("not_assessed"); + expect(statusOf(report, "macho.hardened_runtime")).toBe("not_assessed"); + expect(statusOf(report, "android.manifest")).toBe("not_assessed"); + }); + + it("grades shipped source maps separately from embedded sources", () => { + const embedded = reportFor( + scan({ + source_maps: { + embedded: 1, + without_sources: 0, + unparsed: 0, + skipped: 0, + samples: ["app.js.map"], + }, + }), + ); + expect(statusOf(embedded, "javascript.source_map_sources")).toBe("exposed"); + expect(embedded.checks[0]?.severity).toBe("high"); + expect(embedded.checks[0]?.locations).toEqual([ + { kind: "artifact-path", path: "app.js.map" }, + ]); + expect(statusOf(embedded, "javascript.source_maps")).toBe("not_observed"); + expect(exposureMeetsFailOn(embedded, "high")).toBe(true); + + const mapsOnly = reportFor( + scan({ + source_maps: { + embedded: 0, + without_sources: 2, + unparsed: 0, + skipped: 0, + samples: ["vendor.js.map"], + }, + }), + ); + expect(statusOf(mapsOnly, "javascript.source_maps")).toBe("exposed"); + expect(mapsOnly.checks[1]?.severity).toBe("medium"); + expect(exposureMeetsFailOn(mapsOnly, "high")).toBe(false); + expect(exposureMeetsFailOn(mapsOnly, "medium")).toBe(true); + expect(exposureMeetsFailOn(mapsOnly, "low")).toBe(true); + }); + + it("leaves an unread map unknown unless a confirmed category is already exposed", () => { + const report = reportFor( + scan({ + source_maps: { + embedded: 1, + without_sources: 0, + unparsed: 1, + skipped: 0, + samples: ["app.js.map", "broken.js.map"], + }, + }), + ); + expect(statusOf(report, "javascript.source_map_sources")).toBe("exposed"); + expect(statusOf(report, "javascript.source_maps")).toBe("unknown"); + }); +}); + +describe("release exposure fuse and package checks", () => { + it("derives Electron exposure from fuse outcomes and leaves a missing wire unknown only for Electron", () => { + const insecure = reportFor( + scan(), + owners({ + electron_family: "electron", + fuse_images: [ + { + kind: "report", + role: "app-bundle", + implications: [ + { subject: "electron_run_as_node", outcome: "honored" }, + { + subject: "node_cli_inspect_arguments", + outcome: "honored", + }, + { + subject: "embedded_asar_integrity_validation", + outcome: "not_enforced", + }, + { + subject: "only_load_app_from_asar", + outcome: "not_restricted_to_app_asar", + }, + ], + }, + ], + }), + ); + expect(statusOf(insecure, "electron.run_as_node")).toBe("exposed"); + expect(statusOf(insecure, "electron.node_cli_inspect")).toBe("exposed"); + expect(statusOf(insecure, "electron.asar_integrity")).toBe("exposed"); + expect(statusOf(insecure, "electron.only_load_from_asar")).toBe("exposed"); + expect(insecure.checks[2]?.confidence).toBe("inferred"); + expect(insecure.checks[2]?.severity).toBe("high"); + + const flipped = reportFor( + scan(), + owners({ + electron_family: "electron", + fuse_images: [ + { + kind: "report", + role: "native-image", + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + { + subject: "node_cli_inspect_arguments", + outcome: "not_honored", + }, + { + subject: "embedded_asar_integrity_validation", + outcome: "unknown", + }, + { + subject: "only_load_app_from_asar", + outcome: "restricted_to_app_asar", + }, + ], + }, + ], + }), + ); + expect(statusOf(flipped, "electron.run_as_node")).toBe("not_observed"); + expect(statusOf(flipped, "electron.node_cli_inspect")).toBe("not_observed"); + expect(statusOf(flipped, "electron.asar_integrity")).toBe("unknown"); + expect(statusOf(flipped, "electron.only_load_from_asar")).toBe( + "not_observed", + ); + expect(flipped.checks[2]?.severity).toBeNull(); + + const plain = reportFor( + scan(), + owners({ + electron_family: "not_a_directory", + fuse_images: [ + { + kind: "report", + role: "native-image", + implications: [], + }, + ], + }), + ); + expect(statusOf(plain, "electron.run_as_node")).toBe("not_applicable"); + + const bundle = reportFor( + scan(), + owners({ + electron_family: "electron", + fuse_images: [ + { + kind: "report", + role: "app-bundle", + implications: [], + }, + ], + }), + ); + expect(statusOf(bundle, "electron.run_as_node")).toBe("unknown"); + + const nativeApp = reportFor( + scan(), + owners({ + electron_family: "not_electron", + fuse_images: [ + { + kind: "report", + role: "app-bundle", + path: "/work/Native.app", + implications: [], + }, + ], + }), + ); + expect(statusOf(nativeApp, "electron.run_as_node")).toBe("not_applicable"); + expect(statusOf(nativeApp, "electron.asar_integrity")).toBe( + "not_applicable", + ); + }); +}); + +describe("release exposure fuse sampling", () => { + it.each(["not_a_directory", "not_electron"] as const)( + "keeps fuse applicability unknown for an unread packaged native image under %s", + (family) => { + const report = reportFor( + scan({ + omitted: [ + { + subject: PACKAGED_NATIVE_IMAGES, + count: 1, + reason: "stored inside a package", + }, + ], + }), + owners({ + electron_family: family, + fuse_images: [ + { kind: "report", role: "native-image", implications: [] }, + ], + }), + ); + expect(statusOf(report, "electron.run_as_node")).toBe("unknown"); + }, + ); + + it("keeps a directly selected Electron executable observed when its fuses are safe", () => { + const safe = reportFor( + scan({ + target: { + selected_path: "/work/MyApp.exe", + kind: "executable", + format: "pe", + sha256: SHA, + }, + }), + owners({ + electron_family: "not_a_directory", + fuse_images: [ + { + kind: "report", + role: "native-image", + path: "/work/MyApp.exe", + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + { + subject: "node_cli_inspect_arguments", + outcome: "not_honored", + }, + { + subject: "embedded_asar_integrity_validation", + outcome: "enforced", + }, + { + subject: "only_load_app_from_asar", + outcome: "restricted_to_app_asar", + }, + ], + }, + ], + }), + ); + expect(statusOf(safe, "electron.run_as_node")).toBe("not_observed"); + expect(statusOf(safe, "electron.asar_integrity")).toBe("not_observed"); + + const uncertain = reportFor( + scan(), + owners({ + electron_family: "not_a_directory", + fuse_images: [ + { + kind: "report", + role: "native-image", + implications: [ + { subject: "electron_run_as_node", outcome: "unknown" }, + ], + }, + ], + }), + ); + expect(statusOf(uncertain, "electron.run_as_node")).toBe("unknown"); + + const failed = reportFor( + scan(), + owners({ + electron_family: "not_a_directory", + fuse_images: [ + { + kind: "failed", + role: "native-image", + path: "/work/MyApp.exe", + reason: "inspect_electron_fuses failed", + }, + ], + }), + ); + expect(statusOf(failed, "electron.run_as_node")).toBe("unknown"); + expect( + failed.checks.find((check) => check.id === "electron.run_as_node") + ?.observation.samples, + ).toContain("inspect_electron_fuses failed"); + }); +}); + +describe("release exposure unread fuse images", () => { + it("does not treat a partial native sample as an absent fuse", () => { + const sampled = scan({ + omitted: [ + { + subject: "native images", + count: 1, + reason: + "Only 4 native images are passed to language and fuse readers.", + }, + ], + }); + const implications = [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + ]; + const omitted = reportFor( + sampled, + owners({ + electron_family: "electron", + fuse_images: [{ kind: "report", role: "native-image", implications }], + }), + ); + expect(statusOf(omitted, "electron.run_as_node")).toBe("unknown"); + + const included = reportFor( + sampled, + owners({ + electron_family: "electron", + electron_executable_included: true, + fuse_images: [{ kind: "report", role: "native-image", implications }], + }), + ); + expect(statusOf(included, "electron.run_as_node")).toBe("not_observed"); + }); + + it("does not treat a packaged native image as an absent fuse, even once the host was read", () => { + const packaged = scan({ + omitted: [ + { + subject: PACKAGED_NATIVE_IMAGES, + count: 1, + reason: "Fuse readers run on files on disk.", + }, + ], + }); + const fuseFacts = { + electron_family: "unknown" as const, + fuse_images: [ + { + kind: "report" as const, + role: "native-image" as const, + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + ], + }, + ], + }; + expect( + statusOf( + reportFor( + packaged, + owners({ ...fuseFacts, electron_executable_included: false }), + ), + "electron.run_as_node", + ), + ).toBe("unknown"); + expect( + statusOf( + reportFor( + packaged, + owners({ ...fuseFacts, electron_executable_included: true }), + ), + "electron.run_as_node", + ), + ).toBe("unknown"); + }); + + it("does not treat a partial desktop layout list as an absent fuse", () => { + const fuseFacts = { + electron_family: "electron" as const, + electron_executable_included: true, + fuse_images: [ + { + kind: "report" as const, + role: "native-image" as const, + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + ], + }, + ], + }; + expect( + statusOf( + reportFor( + scan({}), + owners({ ...fuseFacts, electron_layouts_complete: false }), + ), + "electron.run_as_node", + ), + ).toBe("unknown"); + expect( + statusOf(reportFor(scan({}), owners(fuseFacts)), "electron.run_as_node"), + ).toBe("not_observed"); + }); + + it("does not treat incomplete inventory as an absent fuse", () => { + const fuseFacts = { + electron_family: "electron" as const, + fuse_images: [ + { + kind: "report" as const, + role: "native-image" as const, + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + ], + }, + ], + }; + const incomplete = scan({ coverage: "unknown" }); + expect( + statusOf( + reportFor(incomplete, owners(fuseFacts)), + "electron.run_as_node", + ), + ).toBe("unknown"); + expect( + statusOf( + reportFor( + incomplete, + owners({ ...fuseFacts, electron_executable_included: true }), + ), + "electron.run_as_node", + ), + ).toBe("not_observed"); + }); +}); + +describe("release exposure package checks", () => { + it("treats readable package members as inherent exposure and skips encrypted members", () => { + const exposed = reportFor( + scan({ + target: { + selected_path: "/work/app.asar", + kind: "asar", + format: "asar", + sha256: SHA, + }, + extractable: { + formats: ["asar"], + readable_members: 2, + unreadable_members: 0, + samples: ["app.js"], + }, + }), + ); + expect(statusOf(exposed, "packaging.extractable_payload")).toBe("exposed"); + expect( + exposed.checks.find( + (check) => check.id === "packaging.extractable_payload", + )?.severity, + ).toBe("low"); + expect(exposureMeetsFailOn(exposed, "medium")).toBe(false); + expect(exposureMeetsFailOn(exposed, "low")).toBe(true); + + const sealed = reportFor( + scan({ + extractable: { + formats: ["asar"], + readable_members: 0, + unreadable_members: 3, + samples: [], + }, + }), + ); + expect(statusOf(sealed, "packaging.extractable_payload")).toBe("unknown"); + + const unread = reportFor( + scan({ + extractable: { + formats: [], + readable_members: 0, + unreadable_members: 1, + samples: [], + }, + }), + ); + expect(statusOf(unread, "packaging.extractable_payload")).toBe("unknown"); + }); +}); + +describe("release exposure native and text checks", () => { + it("reports debug companions and CodeView paths separately from source maps", () => { + const report = reportFor( + scan({ + debug_companions: { paths: ["/work/app.pdb"], skipped: 0 }, + pdb: { + examined: 1, + paths: ["C:\\build\\app.pdb"], + image_paths: ["/work/app.exe"], + failed: 0, + }, + }), + ); + expect(statusOf(report, "native.debug_companions")).toBe("exposed"); + expect( + report.checks.find((check) => check.id === "native.debug_companions") + ?.locations, + ).toEqual([{ kind: "artifact-path", path: "/work/app.pdb" }]); + expect(statusOf(report, "native.pdb_path")).toBe("exposed"); + const pdb = report.checks.find((check) => check.id === "native.pdb_path"); + expect(pdb?.locations).toEqual([ + { kind: "artifact-path", path: "/work/app.exe" }, + ]); + expect(pdb?.observation.samples).toContain("C:\\build\\app.pdb"); + expect( + report.checks.find((check) => check.id === "native.pdb_path")?.severity, + ).toBe("low"); + }); + + it("keeps null Go and Rust metadata unknown and unsupported images inapplicable", () => { + const unknown = reportFor( + scan(), + owners({ + go_images: [{ kind: "absent" }], + rust_images: [{ kind: "absent" }], + }), + ); + expect(statusOf(unknown, "go.build_paths")).toBe("unknown"); + expect(statusOf(unknown, "rust.dependency_metadata")).toBe("unknown"); + + const exposed = reportFor( + scan(), + owners({ + go_images: [ + { kind: "paths", path: "/work/app", paths: ["/home/dev/main.go"] }, + ], + rust_images: [{ kind: "packages", packages: ["app"] }], + }), + ); + expect(statusOf(exposed, "go.build_paths")).toBe("exposed"); + const go = exposed.checks.find((check) => check.id === "go.build_paths"); + expect(go?.locations).toEqual([ + { kind: "artifact-path", path: "/work/app" }, + ]); + expect(go?.observation.samples).toContain("/home/dev/main.go"); + expect(statusOf(exposed, "rust.dependency_metadata")).toBe("exposed"); + + const omitted = reportFor( + scan({ + coverage: "unknown", + omitted: [ + { + subject: "native images", + count: 2, + reason: "later native images were dropped", + }, + ], + }), + owners({ + go_images: [{ kind: "unsupported" }], + rust_images: [], + }), + ); + expect(statusOf(omitted, "go.build_paths")).toBe("unknown"); + expect(statusOf(omitted, "rust.dependency_metadata")).toBe("unknown"); + + const other = reportFor( + scan(), + owners({ + go_images: [{ kind: "unsupported" }], + rust_images: [{ kind: "unsupported" }], + }), + ); + expect(statusOf(other, "go.build_paths")).toBe("not_applicable"); + expect(statusOf(other, "rust.dependency_metadata")).toBe("not_applicable"); + }); + + it("reports credential rule locations and never stores matched bytes", () => { + const planted = "release-exposure-planted-secret-7c2e9a"; + const report = reportFor( + scan(), + owners({ + credential_markers: { + hits: [{ rule_id: "pem.private_key", path: "config.txt", line: 2 }], + omitted_hits: 0, + }, + }), + ); + expect(statusOf(report, "text.credential_markers")).toBe("exposed"); + expect( + report.checks.find((check) => check.id === "text.credential_markers") + ?.locations, + ).toEqual([{ kind: "artifact-path", path: "config.txt" }]); + const serialized = JSON.stringify(report); + expect(serialized).not.toContain(planted); + expect(serialized).toContain("pem.private_key config.txt:2"); + expect(exposureMeetsFailOn(report, "high")).toBe(true); + }); +}); + +describe("release exposure gate and undecided checks", () => { + it("does not let unknown or unassessed checks trip the gate", () => { + const report = reportFor( + scan({ + source_maps: { + embedded: 0, + without_sources: 0, + unparsed: 1, + skipped: 0, + samples: ["broken.js.map"], + }, + }), + ); + expect(report.summary.by_status.unknown).toBeGreaterThan(0); + expect(report.summary.by_status.not_assessed).toBe(3); + expect(exposureMeetsFailOn(report, "low")).toBe(false); + expect(report.summary.no_exposure_observed_in_assessed_checks).toBe(true); + }); + + it("does not treat an unscanned text member as an absent credential", () => { + const report = reportFor( + scan({ text_members: { scanned: 0, skipped: 0 } }), + ); + expect(statusOf(report, "text.credential_markers")).toBe("not_applicable"); + const scanned = reportFor(); + expect(statusOf(scanned, "text.credential_markers")).toBe("not_observed"); + const skipped = reportFor( + scan({ text_members: { scanned: 1, skipped: 1 } }), + ); + expect(statusOf(skipped, "text.credential_markers")).toBe("unknown"); + }); + + it("does not report absent CodeView paths when member coverage is unknown", () => { + const report = reportFor( + scan({ + pdb: { examined: 1, paths: [], failed: 0 }, + coverage: "unknown", + }), + ); + expect(statusOf(report, "native.pdb_path")).toBe("unknown"); + }); + + it("keeps Go and Rust unknown while a packaged native image is unexamined", () => { + const report = reportFor( + scan({ + omitted: [ + { + subject: PACKAGED_NATIVE_IMAGES, + count: 1, + reason: "stored inside a package", + }, + ], + }), + ); + expect(statusOf(report, "go.build_paths")).toBe("unknown"); + expect(statusOf(report, "rust.dependency_metadata")).toBe("unknown"); + }); + + it("labels a companion's identity as inferred from its name", () => { + const report = reportFor( + scan({ debug_companions: { paths: ["app.map"], skipped: 0 } }), + ); + const check = report.checks.find( + (item) => item.id === "native.debug_companions", + ); + expect(check?.status).toBe("exposed"); + expect(check?.confidence).toBe("inferred"); + }); + + it("names the real @electron/fuses option on every fuse remediation", () => { + const fuses = reportFor().checks.filter((check) => + check.id.startsWith("electron."), + ); + expect(fuses).toHaveLength(4); + for (const check of fuses) { + expect(check.remediation).toContain("FuseV1Options."); + expect(check.remediation).toContain("@electron/fuses"); + expect(check.remediation).not.toContain("ElectronFuseV1"); + } + }); +}); + +describe("go.build_paths locations", () => { + it("names only the images that retain build paths", () => { + const report = reportFor( + scan(), + owners({ + go_images: [ + { kind: "paths", path: "/work/leaky", paths: ["/home/dev/main.go"] }, + { kind: "paths", path: "/work/clean", paths: [] }, + ], + }), + ); + const go = report.checks.find((check) => check.id === "go.build_paths"); + expect(go?.status).toBe("exposed"); + expect(go?.locations).toEqual([ + { kind: "artifact-path", path: "/work/leaky" }, + ]); + }); +}); + +describe("electron host fuses beside other images", () => { + const safeImage = { + kind: "report" as const, + role: "native-image" as const, + path: "/work/app/helper", + implications: [ + { subject: "electron_run_as_node", outcome: "not_honored" }, + { subject: "node_cli_inspect_arguments", outcome: "not_honored" }, + { subject: "embedded_asar_integrity_validation", outcome: "enforced" }, + { subject: "only_load_app_from_asar", outcome: "restricted_to_app_asar" }, + ], + }; + const hostWithoutFuses = { + kind: "report" as const, + role: "native-image" as const, + path: "/work/app/app", + implications: [], + }; + const report = (electron_host_paths?: readonly string[]) => + reportFor( + scan(), + owners({ + electron_family: "electron", + electron_executable_included: true, + ...(electron_host_paths === undefined ? {} : { electron_host_paths }), + fuse_images: [hostWithoutFuses, safeImage], + }), + ); + + it("leaves an unresolved host unknown whatever another image reports", () => { + expect(statusOf(report(["/work/app/app"]), "electron.run_as_node")).toBe( + "unknown", + ); + }); + + it("does not let a fuseless helper unsettle the host's safe answer", () => { + expect(statusOf(report(["/work/app/helper"]), "electron.run_as_node")).toBe( + "not_observed", + ); + expect(statusOf(report(), "electron.run_as_node")).toBe("not_observed"); + }); +}); diff --git a/src/domain/releaseExposure.ts b/src/domain/releaseExposure.ts new file mode 100644 index 0000000000..9d79fb80b0 --- /dev/null +++ b/src/domain/releaseExposure.ts @@ -0,0 +1,959 @@ +import { z } from "zod"; + +import { digestSchema } from "./digests.js"; +import { evidenceLocationSchema, type EvidenceLocation } from "./evidence.js"; +import { + absoluteLocalPathStringSchema, + localPathStringSchema, +} from "./localPath.js"; + +/** Selected-file ceiling for one release-exposure audit. */ +export const MAX_RELEASE_EXPOSURE_FILE_BYTES = 256 * 1024 * 1024; +/** Members inventoried from one installation or package. */ +export const MAX_RELEASE_EXPOSURE_INVENTORY_ENTRIES = 65_536; +/** Bytes hashed while inventorying one installation or package. */ +export const MAX_RELEASE_EXPOSURE_INVENTORY_BYTES = 2 * 1024 * 1024 * 1024; +/** Bytes read from one text member. */ +export const MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES = 256 * 1024; +/** Bytes read across all text members. */ +export const MAX_RELEASE_EXPOSURE_TEXT_BYTES = 4 * 1024 * 1024; +/** Resolved paths retained for HTML script targets across one audit. */ +export const MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES = 1024 * 1024; +/** Resolved paths retained for local source map targets across one audit. */ +export const MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES = 1024 * 1024; +/** Source maps read from one artifact. */ +export const MAX_RELEASE_EXPOSURE_SOURCE_MAPS = 32; +/** Bytes read from one source map. */ +export const MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES = 1024 * 1024; +/** Bytes read to identify a package member that is neither text nor a map. */ +export const MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES = 4096; +/** Bytes read from one PE stored inside a package. */ +export const MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES = 256 * 1024; +/** PE images stored inside packages that are read for debug records. */ +export const MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS = 64; +/** Native images passed to Go, Rust, and Electron fuse readers. */ +export const MAX_RELEASE_EXPOSURE_NATIVE_IMAGES = 4; +/** Paths, rule ids, and other samples kept on one check. */ +export const MAX_RELEASE_EXPOSURE_SAMPLES = 8; + +export const RELEASE_EXPOSURE_OPERATION = "audit_release_exposure"; + +export const RELEASE_EXPOSURE_LIMITATION = + "This report states what static analysis of the selected artifact recovered. It does not establish that the application is secure."; + +export const RELEASE_EXPOSURE_ALLOWANCES = [ + `Selected file reads stop at ${String(MAX_RELEASE_EXPOSURE_FILE_BYTES)} bytes.`, + `Text member scans read at most ${String(MAX_RELEASE_EXPOSURE_TEXT_MEMBER_BYTES)} bytes each and ${String(MAX_RELEASE_EXPOSURE_TEXT_BYTES)} bytes in total.`, + `HTML script targets keep at most ${String(MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES)} bytes of resolved paths. Later targets stay unassessed.`, + `Local source map targets keep at most ${String(MAX_RELEASE_EXPOSURE_MAP_TARGET_BYTES)} bytes of resolved paths. Later targets stay unassessed.`, + `Source map scans read at most ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAPS)} maps and ${String(MAX_RELEASE_EXPOSURE_SOURCE_MAP_BYTES)} bytes each. sourcesContent text is counted and then dropped.`, + `Other files and package members are identified from their first ${String(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES)} bytes, and those without a NUL byte in that prefix are scanned as text. Up to ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_READS)} packaged PE images are read for debug records, ${String(MAX_RELEASE_EXPOSURE_PACKAGED_PE_BYTES)} bytes each.`, + "Packaged ELF, Mach-O, and PE images are read in full and copied to a temporary file to probe for an embedded package. They share one max_file_bytes allowance, and an image that does not fit is not probed.", + `Go, Rust, and Electron fuse readers run on at most ${String(MAX_RELEASE_EXPOSURE_NATIVE_IMAGES)} native images.`, +] as const; + +export const RELEASE_EXPOSURE_STATUSES = [ + "exposed", + "not_observed", + "not_applicable", + "unknown", + "not_assessed", +] as const; +export const RELEASE_EXPOSURE_SEVERITIES = [ + "high", + "medium", + "low", + "info", +] as const; +export const RELEASE_EXPOSURE_FAIL_LEVELS = ["high", "medium", "low"] as const; + +export const RELEASE_EXPOSURE_CHECK_IDS = [ + "javascript.source_map_sources", + "javascript.source_maps", + "electron.run_as_node", + "electron.asar_integrity", + "electron.only_load_from_asar", + "electron.node_cli_inspect", + "packaging.extractable_payload", + "native.debug_companions", + "native.pdb_path", + "go.build_paths", + "rust.dependency_metadata", + "text.credential_markers", + "native.hardening", + "macho.hardened_runtime", + "android.manifest", +] as const; + +const statusSchema = z.enum(RELEASE_EXPOSURE_STATUSES); +const severitySchema = z.enum(RELEASE_EXPOSURE_SEVERITIES); +type Status = z.infer; +type Severity = z.infer; +export type ReleaseExposureCheckId = + (typeof RELEASE_EXPOSURE_CHECK_IDS)[number]; +export type ReleaseExposureFailLevel = + (typeof RELEASE_EXPOSURE_FAIL_LEVELS)[number]; + +const CHECK_TABLE = { + "javascript.source_map_sources": { + severity: "high", + remediation: + "Ship the release without sourcesContent. Remove the source maps or omit sourcesContent from maps that ship.", + next_probe: null, + }, + "javascript.source_maps": { + severity: "medium", + remediation: "Do not ship source maps beside the release.", + next_probe: null, + }, + "electron.run_as_node": { + severity: "high", + remediation: + "Set FuseV1Options.RunAsNode to false with @electron/fuses before packaging.", + next_probe: "inspect_electron_fuses", + }, + "electron.asar_integrity": { + severity: "medium", + remediation: + "Set FuseV1Options.EnableEmbeddedAsarIntegrityValidation to true with @electron/fuses. Electron compiles this check for macOS and Windows; an ELF byte stays unknown.", + next_probe: "inspect_electron_fuses", + }, + "electron.only_load_from_asar": { + severity: "medium", + remediation: + "Set FuseV1Options.OnlyLoadAppFromAsar to true with @electron/fuses so the packaged app loads application code from app.asar.", + next_probe: "inspect_electron_fuses", + }, + "electron.node_cli_inspect": { + severity: "medium", + remediation: + "Set FuseV1Options.EnableNodeCliInspectArguments to false with @electron/fuses so the packaged app ignores --inspect.", + next_probe: "inspect_electron_fuses", + }, + "packaging.extractable_payload": { + severity: "low", + remediation: + "Readable members are part of this package format. Treat packaged sources as public. This check does not rewrite the artifact.", + next_probe: "inspect_artifact", + }, + "native.debug_companions": { + severity: "medium", + remediation: + "Do not ship .pdb, .dSYM, .debug, or linker map files beside the release.", + next_probe: null, + }, + "native.pdb_path": { + severity: "low", + remediation: + "Rebuild without a CodeView PDB path, or accept that the path string is recoverable from the PE. The PDB file itself is a separate check.", + next_probe: null, + }, + "go.build_paths": { + severity: "low", + remediation: + "Rebuild with -trimpath so file paths are not written into the binary. Module names can remain.", + next_probe: "inspect_go_binary", + }, + "rust.dependency_metadata": { + severity: "low", + remediation: + "Omit cargo-auditable metadata from the release if the crate list should not ship.", + next_probe: "inspect_rust_binary", + }, + "text.credential_markers": { + severity: "high", + remediation: + "Remove the provider-prefixed credential from the shipped text and rotate it if it is real. This check reports the rule id and location only.", + next_probe: null, + }, + "native.hardening": { + severity: "info", + remediation: + "This audit does not assess native hardening. Use inspect_binary_hardening when that tool is available.", + next_probe: "inspect_binary_hardening", + }, + "macho.hardened_runtime": { + severity: "info", + remediation: + "This audit does not assess the Mach-O hardened runtime. inspect_signature reads code signatures and would change this tool's effects, so it stays a next probe.", + next_probe: "inspect_signature", + }, + "android.manifest": { + severity: "info", + remediation: + "This audit does not read Android manifests. Use inspect_android_package, which needs JADX.", + next_probe: "inspect_android_package", + }, +} as const satisfies Record< + ReleaseExposureCheckId, + { + readonly severity: Severity; + readonly remediation: string; + readonly next_probe: string | null; + } +>; + +const observationSchema = z.strictObject({ + counts: z.record(z.string().min(1), z.int().nonnegative()), + samples: z.array(z.string().min(1)).max(MAX_RELEASE_EXPOSURE_SAMPLES), +}); + +const releaseExposureCheckSchema = z.strictObject({ + id: z.enum(RELEASE_EXPOSURE_CHECK_IDS), + status: statusSchema, + severity: severitySchema.nullable(), + confidence: z.enum(["observed", "inferred"]), + observation: observationSchema, + locations: z.array(evidenceLocationSchema).max(MAX_RELEASE_EXPOSURE_SAMPLES), + remediation: z.string().min(1), + next_probe: z.string().min(1).nullable(), +}); + +const countSchema = z.strictObject({ + exposed: z.int().nonnegative(), + not_observed: z.int().nonnegative(), + not_applicable: z.int().nonnegative(), + unknown: z.int().nonnegative(), + not_assessed: z.int().nonnegative(), +}); +const severityCountSchema = z.strictObject({ + high: z.int().nonnegative(), + medium: z.int().nonnegative(), + low: z.int().nonnegative(), + info: z.int().nonnegative(), +}); + +export const releaseExposureReportSchema = z + .strictObject({ + target: z.strictObject({ + selected_path: localPathStringSchema, + kind: z.enum(["app", "directory", "asar", "executable", "file"]), + format: z.string().min(1), + sha256: digestSchema.nullable(), + }), + checks: z.array(releaseExposureCheckSchema), + summary: z.strictObject({ + by_status: countSchema, + by_severity: severityCountSchema, + highest_exposed_severity: severitySchema.nullable(), + no_exposure_observed_in_assessed_checks: z.boolean(), + }), + coverage: z.strictObject({ + allowances: z.array(z.string().min(1)), + omitted: z.array( + z.strictObject({ + subject: z.string().min(1), + count: z.int().nonnegative(), + reason: z.string().min(1), + }), + ), + }), + limitations: z.array(z.string().min(1)), + }) + .superRefine((report, context) => { + if ( + report.checks.length !== RELEASE_EXPOSURE_CHECK_IDS.length || + report.checks.some( + (check, index) => check.id !== RELEASE_EXPOSURE_CHECK_IDS[index], + ) + ) + context.addIssue({ + code: "custom", + path: ["checks"], + message: "Checks must be the fixed release-exposure table, in order.", + }); + for (const [index, check] of report.checks.entries()) { + const policy = CHECK_TABLE[check.id]; + if ( + check.remediation !== policy.remediation || + check.next_probe !== policy.next_probe + ) + context.addIssue({ + code: "custom", + path: ["checks", index], + message: "Remediation and next probe are fixed per check.", + }); + if (check.status === "exposed" && check.severity !== policy.severity) + context.addIssue({ + code: "custom", + path: ["checks", index, "severity"], + message: "Exposed severity comes from the check policy.", + }); + if (check.status !== "exposed" && check.severity !== null) + context.addIssue({ + code: "custom", + path: ["checks", index, "severity"], + message: "Severity is null unless the check is exposed.", + }); + } + const summary = summarizeReleaseExposureChecks(report.checks); + if ( + JSON.stringify(summary) !== JSON.stringify(report.summary) || + report.limitations[0] !== RELEASE_EXPOSURE_LIMITATION + ) + context.addIssue({ + code: "custom", + path: ["summary"], + message: "Summary and the leading limitation do not match the checks.", + }); + }); + +export const auditReleaseExposureInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute path to a shipped .app bundle, install directory, ASAR, or executable", + ), + max_file_bytes: z + .int() + .positive() + .max(MAX_RELEASE_EXPOSURE_FILE_BYTES) + .default(MAX_RELEASE_EXPOSURE_FILE_BYTES) + .describe( + "Selected-file, on-disk PE, and shared packaged-image read ceiling. Text, source-map, and other package members use the smaller allowances in coverage.allowances.", + ), +}); + +export type ReleaseExposureReport = z.infer; +export type ReleaseExposureCheck = z.infer; +export type AuditReleaseExposureInput = z.infer< + typeof auditReleaseExposureInputSchema +>; + +export interface ReleaseExposureTargetFact { + readonly selected_path: string; + readonly kind: ReleaseExposureReport["target"]["kind"]; + readonly format: string; + readonly sha256: string | null; +} + +export interface ReleaseExposureNativeImage { + readonly path: string; + readonly format: "elf" | "mach-o" | "mach-o-universal" | "pe"; + /** Digest from inventory, when the audit has one to bind owner reads to. */ + readonly sha256?: string; +} + +export interface ReleaseExposureFuseCandidate { + readonly path: string; + readonly role: "app-bundle" | "native-image"; +} + +/** Omission subject for native images stored inside a package. */ +export const PACKAGED_NATIVE_IMAGES = "packaged native images"; + +export interface ReleaseExposureMarkerHit { + readonly rule_id: string; + readonly path: string; + readonly line: number; +} + +export interface ReleaseExposureScan { + readonly target: ReleaseExposureTargetFact; + readonly subject: { + readonly path: string; + readonly sha256: string; + readonly format: string; + }; + readonly native_images: readonly ReleaseExposureNativeImage[]; + readonly fuse_candidates: readonly ReleaseExposureFuseCandidate[]; + readonly source_maps: { + readonly embedded: number; + readonly without_sources: number; + readonly unparsed: number; + readonly skipped: number; + readonly samples: readonly string[]; + readonly embedded_paths?: readonly string[]; + readonly plain_paths?: readonly string[]; + }; + readonly extractable: { + readonly formats: readonly string[]; + readonly readable_members: number; + readonly unreadable_members: number; + readonly samples: readonly string[]; + }; + readonly debug_companions: { + readonly paths: readonly string[]; + readonly skipped: number; + }; + readonly pdb: { + readonly examined: number; + readonly paths: readonly string[]; + readonly image_paths?: readonly string[]; + readonly failed: number; + }; + readonly text_members: { + readonly scanned: number; + readonly skipped: number; + }; + readonly coverage: "complete" | "unknown"; + readonly omitted: readonly { + readonly subject: string; + readonly count: number; + readonly reason: string; + }[]; + readonly limitations: readonly string[]; +} + +export interface FuseImageFact { + readonly kind: "unsupported" | "failed" | "report"; + readonly role: "app-bundle" | "native-image"; + readonly path?: string; + /** Digest of the file the reader resolved, on a report. */ + readonly sha256?: string; + readonly reason?: string; + readonly implications?: readonly { + readonly subject: string; + readonly outcome: string; + }[]; +} + +export type MetadataImageFact = ( + | { readonly kind: "unsupported" } + | { readonly kind: "failed"; readonly reason: string } + | { readonly kind: "absent" } + | { + readonly kind: "paths"; + readonly paths: readonly string[]; + readonly complete?: boolean; + readonly limitations?: readonly string[]; + } + | { readonly kind: "packages"; readonly packages: readonly string[] } +) & { readonly path?: string }; + +export interface ReleaseExposureOwnerFacts { + readonly electron_family: + | "electron" + | "not_electron" + | "unknown" + | "not_a_directory"; + readonly electron_family_reason?: string | null; + readonly electron_executable_included?: boolean; + /** Images named as the Electron host. Others may be helpers with no fuses. */ + readonly electron_host_paths?: readonly string[]; + /** + * False when desktop discovery stopped before it listed every ASAR layout, + * so a layout it never reached may hold a host whose fuses were not read. + */ + readonly electron_layouts_complete?: boolean; + readonly fuse_images: readonly FuseImageFact[]; + readonly go_images: readonly MetadataImageFact[]; + readonly rust_images: readonly MetadataImageFact[]; + readonly credential_markers: { + readonly hits: readonly ReleaseExposureMarkerHit[]; + readonly omitted_hits: number; + }; +} + +const SEVERITY_RANK: Record = { + info: 1, + low: 2, + medium: 3, + high: 4, +}; +const FAIL_RANK: Record = { + low: 2, + medium: 3, + high: 4, +}; + +const boundSamples = (samples: readonly string[]): string[] => { + const unique: string[] = []; + for (const sample of samples) { + if (sample.length === 0 || unique.includes(sample)) continue; + unique.push(sample); + if (unique.length === MAX_RELEASE_EXPOSURE_SAMPLES) break; + } + return unique; +}; + +const boundLocations = ( + locations: readonly EvidenceLocation[], +): EvidenceLocation[] => locations.slice(0, MAX_RELEASE_EXPOSURE_SAMPLES); + +const makeCheck = ( + id: ReleaseExposureCheckId, + status: Status, + confidence: "observed" | "inferred", + counts: Record, + samples: readonly string[], + locations: readonly EvidenceLocation[], +): ReleaseExposureCheck => { + const policy = CHECK_TABLE[id]; + return { + id, + status, + severity: status === "exposed" ? policy.severity : null, + confidence, + observation: { counts, samples: boundSamples(samples) }, + locations: boundLocations(locations), + remediation: policy.remediation, + next_probe: policy.next_probe, + }; +}; + +const targetLocation = (path: string): EvidenceLocation[] => [ + { kind: "artifact-path", path }, +]; + +const findingLocations = ( + paths: readonly string[], + fallback: string, +): EvidenceLocation[] => { + const locations = [...new Set(paths.filter((path) => path.length > 0))] + .slice(0, MAX_RELEASE_EXPOSURE_SAMPLES) + .map((path) => ({ kind: "artifact-path" as const, path })); + return locations.length > 0 ? locations : targetLocation(fallback); +}; + +const sourceMapCheck = ( + id: ReleaseExposureCheckId, + present: number, + scan: ReleaseExposureScan, +): ReleaseExposureCheck => { + const { source_maps: maps, coverage, target } = scan; + const unresolved = maps.unparsed + maps.skipped > 0 || coverage === "unknown"; + const status: Status = + present > 0 ? "exposed" : unresolved ? "unknown" : "not_observed"; + const findingPaths = + id === "javascript.source_map_sources" + ? (maps.embedded_paths ?? (present > 0 ? maps.samples : [])) + : (maps.plain_paths ?? (present > 0 ? maps.samples : [])); + return makeCheck( + id, + status, + status === "unknown" ? "inferred" : "observed", + { + embedded: maps.embedded, + without_sources: maps.without_sources, + unparsed: maps.unparsed, + skipped: maps.skipped, + }, + maps.samples, + status === "exposed" + ? findingLocations(findingPaths, target.selected_path) + : targetLocation(target.selected_path), + ); +}; + +const FUSE_SUBJECTS = { + "electron.run_as_node": { + subject: "electron_run_as_node", + exposed: "honored", + safe: "not_honored", + }, + "electron.node_cli_inspect": { + subject: "node_cli_inspect_arguments", + exposed: "honored", + safe: "not_honored", + }, + "electron.asar_integrity": { + subject: "embedded_asar_integrity_validation", + exposed: "not_enforced", + safe: "enforced", + }, + "electron.only_load_from_asar": { + subject: "only_load_app_from_asar", + exposed: "not_restricted_to_app_asar", + safe: "restricted_to_app_asar", + }, +} as const; + +/** Fuse implication subjects that decide an Electron check. */ +export const RELEASE_EXPOSURE_FUSE_SUBJECTS = Object.values(FUSE_SUBJECTS).map( + (spec) => spec.subject, +); + +/** + * Whether a native image may have gone unread: inventory stopped early, the + * sample dropped one, or one sits inside a package no owner reader opens. + */ +export const nativeImagesMayBeUnread = (scan: ReleaseExposureScan): boolean => + scan.coverage === "unknown" || + scan.omitted.some( + (item) => + (item.subject === "native images" || + item.subject === PACKAGED_NATIVE_IMAGES) && + item.count > 0, + ); + +/** + * Whether a native image stored inside a package went unread. No fuse reader + * opens it, so reading a host on disk does not settle its fuses. + */ +const packagedNativeImagesUnread = (scan: ReleaseExposureScan): boolean => + scan.omitted.some( + (item) => item.subject === PACKAGED_NATIVE_IMAGES && item.count > 0, + ); + +const fuseCheck = ( + id: keyof typeof FUSE_SUBJECTS, + scan: ReleaseExposureScan, + owners: ReleaseExposureOwnerFacts, +): ReleaseExposureCheck => { + const path = scan.target.selected_path; + const spec = FUSE_SUBJECTS[id]; + const images = owners.fuse_images; + const outcomes = images.flatMap((image) => + image.kind === "report" + ? (image.implications ?? []) + .filter((item) => item.subject === spec.subject) + .map((item) => item.outcome) + : [], + ); + const failed = images.some((image) => image.kind === "failed"); + // A bundle's framework holds its fuses, so an image beside it cannot settle them. + const hostUnresolved = images.some( + (image) => + image.kind !== "failed" && + (image.role === "app-bundle" || + (image.path !== undefined && + owners.electron_host_paths?.includes(image.path) === true)) && + !(image.implications ?? []).some((item) => item.subject === spec.subject), + ); + const partialFuseSample = + owners.electron_layouts_complete === false || + packagedNativeImagesUnread(scan) || + (nativeImagesMayBeUnread(scan) && + owners.electron_executable_included !== true); + let status: Status; + if (outcomes.includes(spec.exposed)) status = "exposed"; + else if (owners.electron_family === "not_electron") + status = nativeImagesMayBeUnread(scan) ? "unknown" : "not_applicable"; + else if (outcomes.includes("unknown") || failed || hostUnresolved) + status = "unknown"; + else if (outcomes.length > 0 && outcomes.every((item) => item === spec.safe)) + status = partialFuseSample ? "unknown" : "not_observed"; + else if (owners.electron_family === "not_a_directory") + status = nativeImagesMayBeUnread(scan) ? "unknown" : "not_applicable"; + else status = "unknown"; + const familyReason = owners.electron_family_reason; + const exposedPaths = images.flatMap((image) => { + if (image.kind !== "report" || image.path === undefined) return []; + const hit = (image.implications ?? []).some( + (item) => item.subject === spec.subject && item.outcome === spec.exposed, + ); + return hit ? [image.path] : []; + }); + return makeCheck( + id, + status, + "inferred", + { + images: images.length, + implications: outcomes.length, + }, + [ + ...images.flatMap((image) => + image.reason === undefined ? [] : [image.reason], + ), + ...(status === "unknown" && + familyReason !== undefined && + familyReason !== null + ? [familyReason] + : []), + ], + status === "exposed" + ? findingLocations(exposedPaths, path) + : targetLocation(path), + ); +}; + +const extractableCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => { + const { extractable, coverage, target } = scan; + let status: Status; + if (extractable.readable_members > 0) status = "exposed"; + else if ( + extractable.unreadable_members > 0 || + extractable.formats.length > 0 || + coverage !== "complete" + ) + status = "unknown"; + else status = "not_observed"; + return makeCheck( + "packaging.extractable_payload", + status, + status === "unknown" ? "inferred" : "observed", + { + readable_members: extractable.readable_members, + unreadable_members: extractable.unreadable_members, + formats: extractable.formats.length, + }, + [...extractable.formats, ...extractable.samples], + status === "exposed" + ? findingLocations(extractable.samples, target.selected_path) + : targetLocation(target.selected_path), + ); +}; + +const companionCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => { + const { debug_companions: companions, coverage, target } = scan; + const status: Status = + companions.paths.length > 0 + ? "exposed" + : companions.skipped > 0 || coverage === "unknown" + ? "unknown" + : "not_observed"; + return makeCheck( + "native.debug_companions", + status, + // A companion's identity comes from its name or an unparseable .map body. + status === "not_observed" ? "observed" : "inferred", + { companions: companions.paths.length, skipped: companions.skipped }, + companions.paths, + status === "exposed" + ? findingLocations(companions.paths, target.selected_path) + : targetLocation(target.selected_path), + ); +}; + +const pdbCheck = (scan: ReleaseExposureScan): ReleaseExposureCheck => { + const { pdb, coverage, target } = scan; + let status: Status; + if (pdb.paths.length > 0) status = "exposed"; + else if (pdb.failed > 0 || coverage !== "complete") status = "unknown"; + else if (pdb.examined === 0) status = "not_applicable"; + else status = "not_observed"; + const imagePaths = pdb.image_paths ?? []; + return makeCheck( + "native.pdb_path", + status, + status === "unknown" ? "inferred" : "observed", + { examined: pdb.examined, paths: pdb.paths.length, failed: pdb.failed }, + pdb.paths, + status === "exposed" + ? findingLocations(imagePaths, target.selected_path) + : targetLocation(target.selected_path), + ); +}; + +const metadataStatus = ( + images: readonly MetadataImageFact[], + present: (image: MetadataImageFact) => boolean, + unresolved: boolean, +): Status => { + if (images.some(present)) return "exposed"; + if ( + unresolved || + images.some((image) => image.kind === "failed" || image.kind === "absent") + ) + return "unknown"; + if ( + images.length === 0 || + images.every((image) => image.kind === "unsupported") + ) + return "not_applicable"; + return "not_observed"; +}; + +const retainsBuildPaths = (image: MetadataImageFact): boolean => + image.kind === "paths" && image.paths.length > 0; + +const goCheck = ( + scan: ReleaseExposureScan, + images: readonly MetadataImageFact[], +): ReleaseExposureCheck => { + const path = scan.target.selected_path; + const paths = images.flatMap((image) => + image.kind === "paths" ? image.paths : [], + ); + const reasons = images.flatMap((image) => + image.kind === "failed" ? [image.reason] : [], + ); + const imagePaths = images.flatMap((image) => + retainsBuildPaths(image) && image.path !== undefined ? [image.path] : [], + ); + const status = metadataStatus( + images, + retainsBuildPaths, + nativeImagesMayBeUnread(scan) || + images.some( + (image) => image.kind === "paths" && image.complete === false, + ), + ); + return makeCheck( + "go.build_paths", + status, + status === "unknown" ? "inferred" : "observed", + { images: images.length, paths: paths.length }, + [...paths, ...reasons], + status === "exposed" + ? findingLocations(imagePaths, path) + : targetLocation(path), + ); +}; + +const rustCheck = ( + scan: ReleaseExposureScan, + images: readonly MetadataImageFact[], +): ReleaseExposureCheck => { + const path = scan.target.selected_path; + const packages = images.flatMap((image) => + image.kind === "packages" ? image.packages : [], + ); + const reasons = images.flatMap((image) => + image.kind === "failed" ? [image.reason] : [], + ); + const imagePaths = images.flatMap((image) => + image.kind === "packages" && image.path !== undefined ? [image.path] : [], + ); + const status = metadataStatus( + images, + (image) => image.kind === "packages", + nativeImagesMayBeUnread(scan), + ); + return makeCheck( + "rust.dependency_metadata", + status, + status === "unknown" ? "inferred" : "observed", + { images: images.length, packages: packages.length }, + [...packages, ...reasons], + status === "exposed" + ? findingLocations(imagePaths, path) + : targetLocation(path), + ); +}; + +const credentialCheck = ( + scan: ReleaseExposureScan, + markers: ReleaseExposureOwnerFacts["credential_markers"], +): ReleaseExposureCheck => { + const { text_members: members, coverage, target } = scan; + const counts: Record = { + scanned_members: members.scanned, + skipped_members: members.skipped, + omitted_hits: markers.omitted_hits, + }; + for (const hit of markers.hits) + counts[hit.rule_id] = (counts[hit.rule_id] ?? 0) + 1; + const status: Status = + markers.hits.length > 0 + ? "exposed" + : members.skipped > 0 || coverage === "unknown" + ? "unknown" + : members.scanned === 0 + ? "not_applicable" + : "not_observed"; + return makeCheck( + "text.credential_markers", + status, + status === "unknown" ? "inferred" : "observed", + counts, + markers.hits.map((hit) => `${hit.rule_id} ${hit.path}:${String(hit.line)}`), + status === "exposed" + ? findingLocations( + markers.hits.map((hit) => hit.path), + target.selected_path, + ) + : targetLocation(target.selected_path), + ); +}; + +const notAssessed = ( + id: "native.hardening" | "macho.hardened_runtime" | "android.manifest", + path: string, +): ReleaseExposureCheck => + makeCheck( + id, + "not_assessed", + "inferred", + { assessed: 0 }, + [], + targetLocation(path), + ); + +/** Build the fixed check list from reader facts and owner results. */ +export const assembleReleaseExposureReport = ( + scan: ReleaseExposureScan, + owners: ReleaseExposureOwnerFacts, +): ReleaseExposureReport => { + const path = scan.target.selected_path; + const checks = [ + sourceMapCheck( + "javascript.source_map_sources", + scan.source_maps.embedded, + scan, + ), + sourceMapCheck( + "javascript.source_maps", + scan.source_maps.without_sources, + scan, + ), + fuseCheck("electron.run_as_node", scan, owners), + fuseCheck("electron.asar_integrity", scan, owners), + fuseCheck("electron.only_load_from_asar", scan, owners), + fuseCheck("electron.node_cli_inspect", scan, owners), + extractableCheck(scan), + companionCheck(scan), + pdbCheck(scan), + goCheck(scan, owners.go_images), + rustCheck(scan, owners.rust_images), + credentialCheck(scan, owners.credential_markers), + notAssessed("native.hardening", path), + notAssessed("macho.hardened_runtime", path), + notAssessed("android.manifest", path), + ]; + return releaseExposureReportSchema.parse({ + target: scan.target, + checks, + summary: summarizeReleaseExposureChecks(checks), + coverage: { + allowances: [...RELEASE_EXPOSURE_ALLOWANCES], + omitted: scan.omitted.filter((item) => item.count > 0), + }, + limitations: [ + RELEASE_EXPOSURE_LIMITATION, + ...scan.limitations.filter( + (item) => item !== RELEASE_EXPOSURE_LIMITATION, + ), + ...owners.go_images.flatMap((image) => + image.kind === "paths" ? (image.limitations ?? []) : [], + ), + ], + }); +}; + +/** Count check outcomes. Severity counts include exposed checks only. */ +export const summarizeReleaseExposureChecks = ( + checks: readonly ReleaseExposureCheck[], +): ReleaseExposureReport["summary"] => { + const byStatus = { + exposed: 0, + not_observed: 0, + not_applicable: 0, + unknown: 0, + not_assessed: 0, + }; + const bySeverity = { high: 0, medium: 0, low: 0, info: 0 }; + let highest: Severity | null = null; + for (const check of checks) { + byStatus[check.status] += 1; + if (check.status !== "exposed" || check.severity === null) continue; + bySeverity[check.severity] += 1; + if ( + highest === null || + SEVERITY_RANK[check.severity] > SEVERITY_RANK[highest] + ) + highest = check.severity; + } + return { + by_status: byStatus, + by_severity: bySeverity, + highest_exposed_severity: highest, + no_exposure_observed_in_assessed_checks: byStatus.exposed === 0, + }; +}; + +/** + * Whether an exposed check meets a CI threshold. + * `high` matches high. `medium` matches high and medium. `low` matches high, + * medium, and low. Unknown and not_assessed checks do not match. + */ +export const exposureMeetsFailOn = ( + report: ReleaseExposureReport, + level: ReleaseExposureFailLevel, +): boolean => + report.checks.some( + (check) => + check.status === "exposed" && + check.severity !== null && + SEVERITY_RANK[check.severity] >= FAIL_RANK[level], + ); diff --git a/src/domain/releaseExposureCredentialMarkers.test.ts b/src/domain/releaseExposureCredentialMarkers.test.ts new file mode 100644 index 0000000000..7e3ce43443 --- /dev/null +++ b/src/domain/releaseExposureCredentialMarkers.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { scanCredentialMarkers } from "./releaseExposureCredentialMarkers.js"; + +const PLANTED = "release-exposure-planted-secret-7c2e9a"; +const AWS_KEY = "AKIAPLANT9EXPOSUREXX"; + +describe("release exposure credential markers", () => { + it("reports rule id and location without the matched bytes", () => { + const scan = scanCredentialMarkers( + [ + "-----BEGIN PRIVATE KEY-----", + PLANTED, + "-----END PRIVATE KEY-----", + `token ${AWS_KEY}`, + "github_pat_11AAAAAABBBBCCCCDDDDEE", + "ordinary configuration", + ].join("\n"), + "resources/config.txt", + ); + expect(scan.hits.map((hit) => hit.rule_id)).toEqual([ + "pem.private_key", + "aws.access_key_id", + "github.fine_grained_pat", + ]); + expect(scan.hits.map((hit) => hit.line)).toEqual([1, 4, 5]); + const serialized = JSON.stringify(scan); + expect(serialized).not.toContain(PLANTED); + expect(serialized).not.toContain(AWS_KEY); + expect(serialized).not.toContain("github_pat_11AAAAAABBBBCCCCDDDDEE"); + expect(serialized).not.toContain("BEGIN PRIVATE KEY"); + }); + + it("counts hits past the storage cap without retaining them", () => { + const line = "npm_" + "a".repeat(36); + const scan = scanCredentialMarkers( + Array.from({ length: 40 }, () => line).join("\n"), + "tokens.txt", + ); + expect(scan.hits).toHaveLength(32); + expect(scan.omitted_hits).toBe(8); + expect(JSON.stringify(scan)).not.toContain(line); + }); +}); + +describe("release exposure credential marker boundaries", () => { + const GOOGLE_KEY = `AIza${"A".repeat(34)}-`; + const GITLAB_PAT = `glpat-${"a".repeat(19)}-`; + const SLACK_TOKEN = `xoxb-${"a".repeat(9)}-`; + + const ruleIds = (text: string) => + scanCredentialMarkers(text, "config.json").hits.map((hit) => hit.rule_id); + + it.each([ + ["google.api_key", GOOGLE_KEY], + ["gitlab.pat", GITLAB_PAT], + ["slack.token", SLACK_TOKEN], + ])("reports a %s that ends in a hyphen", (ruleId, token) => { + expect(ruleIds(JSON.stringify({ token }))).toEqual([ruleId]); + expect(ruleIds(`${token} next`)).toEqual([ruleId]); + expect(ruleIds(token)).toEqual([ruleId]); + }); + + it("reports a full-length GitHub refresh token", () => { + const refreshToken = `ghr_${"a".repeat(76)}`; + expect(ruleIds(JSON.stringify({ token: refreshToken }))).toEqual([ + "github.refresh_token", + ]); + expect(ruleIds(`ghr_${"a".repeat(36)}`)).toEqual([]); + expect(ruleIds(`gho_${"a".repeat(36)}`)).toEqual(["github.token"]); + }); + + it("keeps a Google API key to its exact length", () => { + expect(ruleIds(`${GOOGLE_KEY}-`)).toEqual([]); + expect(ruleIds(`${GOOGLE_KEY}A`)).toEqual([]); + expect(ruleIds(`AIza${"A".repeat(35)}-suffix`)).toEqual([]); + }); +}); diff --git a/src/domain/releaseExposureCredentialMarkers.ts b/src/domain/releaseExposureCredentialMarkers.ts new file mode 100644 index 0000000000..840169b6df --- /dev/null +++ b/src/domain/releaseExposureCredentialMarkers.ts @@ -0,0 +1,73 @@ +/** High-precision credential markers for an owner-side release audit. */ + +/* + * A token alphabet that includes "-" cannot end on `\b`: a final hyphen before + * a quote, whitespace, or end of input has no word boundary after it. Open-ended + * rules consume the whole token, and fixed-length rules check the full alphabet. + */ +const MARKER_RULES = [ + { + id: "pem.private_key", + pattern: + /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/u, + }, + { id: "aws.access_key_id", pattern: /\bAKIA[0-9A-Z]{16}\b/u }, + { id: "github.token", pattern: /\bgh[opus]_[A-Za-z0-9]{36}\b/u }, + { id: "github.refresh_token", pattern: /\bghr_[A-Za-z0-9]{76}\b/u }, + { + id: "github.fine_grained_pat", + pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/u, + }, + { id: "gitlab.pat", pattern: /\bglpat-[A-Za-z0-9_-]{20,}/u }, + { id: "slack.token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/u }, + { id: "stripe.live_secret", pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b/u }, + { + id: "google.api_key", + pattern: /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/u, + }, + { id: "npm.token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/u }, +] as const; + +/** Rule ids this scanner can report. Matched bytes are never returned. */ +export const CREDENTIAL_MARKER_RULE_IDS = MARKER_RULES.map((rule) => rule.id); + +/** A hit records the rule and where it matched. It does not record the bytes. */ +export interface CredentialMarkerHit { + readonly rule_id: (typeof CREDENTIAL_MARKER_RULE_IDS)[number]; + readonly path: string; + readonly line: number; +} + +export interface CredentialMarkerScan { + readonly hits: readonly CredentialMarkerHit[]; + readonly omitted_hits: number; +} + +const HIT_LIMIT = 32; + +/** + * Scan one text member line by line. + * The returned value contains rule ids, paths, and 1-based line numbers only. + */ +export const scanCredentialMarkers = ( + text: string, + path: string, +): CredentialMarkerScan => { + const hits: CredentialMarkerHit[] = []; + let omittedHits = 0; + const lines = text.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + for (const rule of MARKER_RULES) { + if (!rule.pattern.test(line)) continue; + if (hits.length >= HIT_LIMIT) omittedHits += 1; + else + hits.push({ + rule_id: rule.id, + path, + line: index + 1, + }); + } + } + return { hits, omitted_hits: omittedHits }; +}; diff --git a/src/domain/releaseExposureGoPaths.ts b/src/domain/releaseExposureGoPaths.ts new file mode 100644 index 0000000000..43ebd5a218 --- /dev/null +++ b/src/domain/releaseExposureGoPaths.ts @@ -0,0 +1,387 @@ +import type { GoBinary } from "./go/goBinary.js"; +import type { ReleaseExposureNativeImage } from "./releaseExposure.js"; + +const filesystemPath = (value: string | null | undefined): string | null => { + if (value === null || value === undefined || value.length === 0) return null; + if ( + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + value.includes("\\") || + /^[A-Za-z]:[\\/]/u.test(value) + ) + return value; + return null; +}; + +/** Go build settings whose values are flags. */ +const GO_FLAG_SETTING = + /^(?:CGO_[A-Z]+FLAGS|-asmflags|-gccgoflags|-gcflags|-ldflags)$/u; +/** + * `-pgo file` names one profile, so its value is a whole path, or under + * `-trimpath` the bare file name; `auto` and `off` name no file. + */ +const goProfilePath = (value: string): string | null => + value.length === 0 || value === "auto" || value === "off" ? null : value; +/** + * One field as Go's `quoted.Split` reads a flag value: a field that begins with + * `"` or `'` runs to the matching quote, which it drops; any other field runs to + * whitespace and keeps every quote in it as a literal character. + */ +const GO_FLAG_FIELD = /"([^"]*)"|'([^']*)'|[^\s"'][^\s]*/gu; +/** A path after a flag prefix, whitespace, `=`, or `,`, as in `-isystem/opt/x`. */ +const GO_FLAG_PATH = + /(?:^|[\s=,])(?:-(?:idirafter|imacros|include|iprefix|iquote|isysroot|isystem|[BFILT]))?((?:\.{1,2}[\\/]|\/|[A-Za-z]:[\\/]|\\{2}[^\\\s=,]+\\)[^=,]+)/gu; + +/** + * Settings read by a C compiler, where the flags below take a directory or file. + * Go's own tools reuse those letters: `-ldflags` takes `-B gobuildid` and + * `-T 0x1000`, so their relative arguments are not paths. The exact field + * `-I-` is GCC's include barrier, the same as `--include-barrier`, and names + * no directory. + */ +const GO_COMPILER_SETTING = /^(?:CGO_[A-Z]+FLAGS|-gccgoflags)$/u; +const GO_COMPILER_PATH_FLAG = + /^(?:-(?:idirafter|imacros|include|iprefix|iquote|isysroot|isystem|[BFLT]|I(?!-$))=?\s*|--sysroot(?:=|\s+|$))(.*)$/su; +/** + * Linker options a C compiler forwards through `-Wl,a,b` or `-Xlinker a`. The + * linker reads its own letters: `-L`, `-T` and `-I` (the ELF interpreter) take + * a path, `-Bstatic` does not, and `-Ttext=0x400000` and its siblings set a + * section or segment address. `-rpath` and `-rpath-link` take a runtime or + * link-time library search directory. `--version-script`, `--dynamic-list` and + * `--retain-symbols-file` read a file, as `--script` does, and `-Map` writes + * the linker map to one. `--just-symbols` reads a symbol file; it is the long + * spelling of `-R`, but only `-R` turns into `-rpath` for a directory. + * `--sysroot` sets the linker's own sysroot directory, apart from the one the + * compiler driver reads. + */ +const LINKER_PATH_FLAG = + /^(?:-(?!T(?:bss|data|text|(?:ldata|rodata|text)-segment)(?:=|$))[ILRT]|-{1,2}(?:dynamic-linker|dynamic-list|just-symbols|library-path|Map|retain-symbols-file|rpath|rpath-link|script|sysroot|version-script)(?==|$))=?(.*)$/su; +type NativeImageFormat = ReleaseExposureNativeImage["format"]; +/** + * A runtime search path the image's loader expands, which names no directory + * on the build host. The ELF loader expands `$ORIGIN/../lib`; dyld expands + * `@loader_path/Frameworks`. Each token is literal text to the other loader, + * and the PE loader reads no search list, so it expands neither. + */ +const DYLD_RUNTIME_TOKEN = /^@(?:executable_path|loader_path|rpath)(?:\/|$)/u; +const LINKER_RUNTIME_TOKENS: Record = { + elf: /^(?:\$(?:ORIGIN|LIB|PLATFORM)\b|\$\{(?:ORIGIN|LIB|PLATFORM)\})/u, + "mach-o": DYLD_RUNTIME_TOKEN, + "mach-o-universal": DYLD_RUNTIME_TOKEN, + pe: null, +}; +/** `-rpath` and `-rpath-link` take a search list, not one directory. */ +const LINKER_SEARCH_LIST_FLAG = /^-{1,2}(?:rpath|rpath-link)(?==|$)/u; +/** + * The `:` between search list entries. A `:` after a lone drive letter, as in + * `C:\lib`, is part of the entry. + */ +const LINKER_SEARCH_LIST_SEPARATOR = /(? + [...value.matchAll(GO_FLAG_FIELD)].map( + ([field, double, single]) => double ?? single ?? field, + ); + +/** Rooted paths in flags, with their `=` and `,` structure kept. */ +const flagPaths = (flags: string[]): string[] => + flags.flatMap((flag) => + [...flag.matchAll(GO_FLAG_PATH)].flatMap(([, path]) => + path === undefined ? [] : [path], + ), + ); + +/** Consume each normalized linker option and its operand exactly once. */ +const linkerPaths = ( + flags: string[], + format: NativeImageFormat, + onAmbiguousLinkerR: () => void, +): { paths: string[]; searchPaths: string[]; remaining: string[] } => { + const paths: string[] = []; + const searchPaths: string[] = []; + const remaining: string[] = []; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + if (flag === undefined) continue; + const attached = LINKER_PATH_FLAG.exec(flag)?.[1]; + if (attached === undefined) { + remaining.push(flag); + continue; + } + const next = flags[index + 1]; + const fromNext = + attached.length === 0 && !flag.endsWith("=") && next !== undefined; + const argument = fromNext ? next : attached; + if (fromNext) index += 1; + if (argument.length === 0) continue; + if (LINKER_SEARCH_LIST_FLAG.test(flag)) { + searchPaths.push(...searchListEntries(argument, format)); + continue; + } + if ( + flag.startsWith("-R") && + format === "elf" && + LINKER_RUNTIME_TOKENS.elf?.test(argument) === true + ) { + onAmbiguousLinkerR(); + continue; + } + if (/^-{1,2}Map(?:=|$)/u.test(flag) && argument === "-") continue; + paths.push(argument); + } + return { paths, searchPaths, remaining }; +}; + +/** + * `-trimpath` takes `prefix=replacement` rewrites separated by `;`, so its + * rooted value is read by `flagPaths` with that structure kept. + */ +const GO_PATH_LIST_FLAG = /^-{1,2}trimpath(?:=|\s+|$)/u; +/** + * `go tool link -r` sets the dynamic linker search path, `dir1:dir2:...`, so + * it is read entry by entry like a forwarded `-rpath`. + */ +const GO_LINKER_SEARCH_LIST_FLAG = /^-{1,2}r(?:=|\s+|$)/u; + +/** + * A runtime search list's build-host entries. Only the ELF loader splits a + * list at `:`; dyld reads each argument as one directory. An entry the image's + * loader expands, such as `$ORIGIN` on ELF, drops only itself. + */ +const searchListEntries = ( + list: string, + format: NativeImageFormat, +): string[] => { + const token = LINKER_RUNTIME_TOKENS[format]; + return ( + format === "elf" ? list.split(LINKER_SEARCH_LIST_SEPARATOR) : [list] + ).filter((entry) => entry.length > 0 && token?.test(entry) !== true); +}; + +/** Consume compiler operands before interpreting any remaining forwarding syntax. */ +const splitForwardedLinkerFlags = ( + flags: string[], +): { compiler: string[]; linker: string[]; paths: string[] } => { + const compiler: string[] = []; + const linker: string[] = []; + const paths: string[] = []; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + if (flag === undefined) continue; + const attached = GO_COMPILER_PATH_FLAG.exec(flag)?.[1]; + if (attached !== undefined) { + const next = flags[index + 1]; + const fromNext = + attached.length === 0 && !flag.endsWith("=") && next !== undefined; + const argument = fromNext ? next : attached; + if (argument.length > 0) paths.push(argument); + if (fromNext) index += 1; + } else if (flag === "-Xlinker" && flags[index + 1] !== undefined) { + const forwarded = flags[index + 1]; + if (forwarded !== undefined) linker.push(forwarded); + index += 1; + } else if (flag.startsWith("-Wl,")) + linker.push(...flag.slice(4).split(",")); + else compiler.push(flag); + } + return { compiler, linker, paths }; +}; + +/** + * Options whose separate argument is a dyld install name or path, which may + * start with a runtime token such as `@rpath`: `-install_name @rpath/libx.dylib`. + */ +const LINKER_DYLD_VALUE_FLAG = + /^-{1,2}(?:dylib_file|dylib_install_name|install_name|rpath)$/u; + +/** + * Each `@FILE` response file the compiler driver or linker reads options from, + * named in its own argument and never opened here. An argument after an option + * that takes one is that option's value, as is a dyld runtime token after + * `-install_name`; a standalone `@rpath/x.rsp` is still a response file. + */ +const responseFilePaths = (args: string[], pathFlag: RegExp): string[] => + args.flatMap((arg, index) => { + if (!arg.startsWith("@")) return []; + const path = arg.slice(1); + const previous = args[index - 1]; + return path.length === 0 || + (previous !== undefined && + (pathFlag.exec(previous)?.[1] === "" || + LINKER_DYLD_VALUE_FLAG.test(previous))) + ? [] + : [path]; + }); + +/** + * Rooted and relative paths in flags a C compiler driver reads. A forwarded + * search list is read entry by entry, so an entry the image's loader expands, + * such as `$ORIGIN` on ELF, drops only itself. Only a search list is loader-expanded: the + * argument of `-T`, `-L` or `-I` is a literal path even when it reads `$ORIGIN`. + * Only the ELF linker splits a search list at `:`. The Mach-O linker stores + * each `-rpath` argument as one directory, so `@loader_path/lib:extra` is one + * loader-relative entry. + */ +const compilerPaths = ( + value: string, + format: NativeImageFormat, + onAmbiguousLinkerR: () => void, +): string[] => { + const { compiler, linker, paths } = splitForwardedLinkerFlags( + goFields(value), + ); + const extracted = linkerPaths(linker, format, onAmbiguousLinkerR); + return [ + ...flagPaths([...compiler, ...extracted.remaining]), + ...paths, + ...extracted.paths, + ...responseFilePaths(compiler, GO_COMPILER_PATH_FLAG), + ...responseFilePaths(extracted.remaining, LINKER_PATH_FLAG), + ...extracted.searchPaths, + ]; +}; + +/** Interpret a consumed Go path operand without treating its bytes as more flags. */ +const goToolOperandPaths = ( + flag: string, + argument: string, + pathFlag: RegExp | undefined, + format: NativeImageFormat, +): { paths: string[]; remaining: string[] } => { + if ( + argument.length === 0 || + (pathFlag === GO_LINKER_PATH_FLAG && + /^-{1,2}libgcc(?:=|$)/u.test(flag) && + argument === "none") + ) + return { paths: [], remaining: [] }; + if (GO_PATH_LIST_FLAG.test(flag)) + return { + paths: filesystemPath(argument) === null ? [argument] : [], + remaining: [argument], + }; + return { + paths: GO_LINKER_SEARCH_LIST_FLAG.test(flag) + ? searchListEntries(argument, format) + : [argument], + remaining: [], + }; +}; + +/** + * Paths in a flag setting. Each `-extldflags` value is read only as the + * external linker splits it, so a path quoted inside it is read whole. + */ +const goSettingPaths = ( + key: string, + value: string, + format: NativeImageFormat, + onAmbiguousLinkerR: () => void, +): string[] => { + if (GO_COMPILER_SETTING.test(key)) + return compilerPaths(value, format, onAmbiguousLinkerR); + const pathFlag = GO_TOOL_PATH_FLAGS.get(key); + const fields = goFields(value); + const paths: string[] = []; + const external: string[] = []; + const remaining: string[] = []; + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index]; + if (field === undefined) continue; + const flag = index === 0 ? field.replace(GO_PACKAGE_SELECTOR, "") : field; + const attached = pathFlag?.exec(flag)?.[1]; + if (attached !== undefined) { + const next = fields[index + 1]; + const fromNext = + attached.length === 0 && !flag.endsWith("=") && next !== undefined; + const argument = fromNext ? next : attached; + if (fromNext) index += 1; + const operand = goToolOperandPaths(flag, argument, pathFlag, format); + paths.push(...operand.paths); + remaining.push(...operand.remaining); + continue; + } + const externalFlags = GO_EXTERNAL_LINKER_FLAGS.exec(flag); + if (externalFlags !== null) { + const argument = externalFlags[1] ?? fields[++index]; + if (argument !== undefined) external.push(argument); + continue; + } + remaining.push(field); + if (/^-{1,2}X$/u.test(flag)) { + const argument = fields[++index]; + if (argument !== undefined) remaining.push(argument); + } + } + return [ + ...flagPaths(remaining), + ...paths, + ...external.flatMap((flags) => + compilerPaths(flags, format, onAmbiguousLinkerR), + ), + ]; +}; + +export const goBuildPaths = ( + module: NonNullable["module"], + format: NativeImageFormat, +): { readonly paths: string[]; readonly ambiguousLinkerR: boolean } => { + const paths = new Set(); + let ambiguousLinkerR = false; + const add = (value: string | null | undefined) => { + const path = filesystemPath(value); + if (path !== null) paths.add(path); + }; + add(module.path); + add(module.main?.path); + add(module.main?.replacement?.path); + for (const dependency of module.dependencies) { + add(dependency.path); + add(dependency.replacement?.path); + } + for (const setting of module.settings) { + if (setting.key === "-pgo") { + const path = goProfilePath(setting.value); + if (path !== null) paths.add(path); + continue; + } + if (!GO_FLAG_SETTING.test(setting.key)) continue; + for (const path of goSettingPaths( + setting.key, + setting.value, + format, + () => { + ambiguousLinkerR = true; + }, + )) + paths.add(path); + } + return { paths: [...paths], ambiguousLinkerR }; +}; diff --git a/src/domain/textByteOrderMark.ts b/src/domain/textByteOrderMark.ts new file mode 100644 index 0000000000..5ba3ca497f --- /dev/null +++ b/src/domain/textByteOrderMark.ts @@ -0,0 +1,20 @@ +/** + * The wide Unicode encoding a byte order mark declares, or null when there is + * none. UTF-32 is reported apart because no consumer decodes it, and it is + * checked first because its little-endian mark begins with UTF-16's. + */ +export const byteOrderMarkEncoding = ( + bytes: Uint8Array, +): "utf-16le" | "utf-16be" | "utf-32" | null => { + if ( + (bytes[0] === 0xff && + bytes[1] === 0xfe && + bytes[2] === 0 && + bytes[3] === 0) || + (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 0xfe && bytes[3] === 0xff) + ) + return "utf-32"; + if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le"; + if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be"; + return null; +}; diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index a38c2f21b4..9f86ee3581 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -129,6 +129,7 @@ export const readGoBinaryImage = ( throw new BinaryFormatFailure( "unsupported", "Go build-info flags contain an unsupported format bit", + true, ); if ((flags & 2) === 0 && (flags & 1) !== (byte_order === "big" ? 1 : 0)) throw new BinaryFormatFailure( diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 1b3e32f42f..9f241cb446 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -117,6 +117,7 @@ const failureForPrimary = ( return cause.kind === "unsupported" ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { cause, + undecodedContent: cause.undecodedContent, }) : new AnalysisInputError(OPERATION, { cause }, [ { diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts index 435eb710ff..0941cf5df3 100644 --- a/src/go/GoBuildInfoSearch.ts +++ b/src/go/GoBuildInfoSearch.ts @@ -70,6 +70,7 @@ export const goBuildInfoSearch = ( throw new BinaryFormatFailure( "unsupported", "Compressed ELF build-info sections are unsupported", + true, ); const search = { ...reader.fileRange( diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index 4fe7a051d2..1a7a13a095 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -67,6 +67,16 @@ export class SourceMapFormatFailure extends Error { } } +/** Report schema rejection through the one failure type consumers classify. */ +const parseStructure = ( + schema: Schema, + value: unknown, +): z.output => { + const parsed = schema.safeParse(value); + if (parsed.success) return parsed.data; + throw new SourceMapFormatFailure("format", parsed.error.message); +}; + /** Raw declaration identity, separate from flattened engine URL identity. */ export interface SourceMapSourceDeclaration { readonly sectionPath: readonly number[]; @@ -153,7 +163,7 @@ const addSections = ({ const limits = options.limits ?? WEB_SOURCE_MAP_LIMITS; options.check?.(); consumeRecords(budget, limits, rawSections.length); - const sections = z.array(sectionSchema).parse(rawSections); + const sections = parseStructure(z.array(sectionSchema), rawSections); const embeddedMaps: Extract< SourceMapSection, { readonly kind: "embedded" } @@ -279,7 +289,7 @@ const inspectLeaf = ( node.value === null || Array.isArray(node.value) ) - return leafSchema.parse(node.value); + return parseStructure(leafSchema, node.value); const rawMap = node.value as Readonly>; const rawSources = rawMap.sources; const rawNames = rawMap.names; @@ -296,7 +306,7 @@ const inspectLeaf = ( Array.isArray(rawContents) ? rawContents.length : 0, ); options.check?.(); - const leaf = leafSchema.parse(node.value); + const leaf = parseStructure(leafSchema, node.value); const layout = inspectMappingLayout(leaf.mappings, limits, options.check); consumeRecords(budget, limits, layout.segments + layout.lines); if ( @@ -332,7 +342,7 @@ export const inspectSourceMapValue = ( } => { const limits = options.limits ?? WEB_SOURCE_MAP_LIMITS; options.check?.(); - const root = mapHeaderSchema.parse(value); + const root = parseStructure(mapHeaderSchema, value); const declarations: SourceMapSourceDeclaration[] = []; const leaves: SourceMapLeaf[] = []; const pending: PendingMap[] = [ @@ -390,3 +400,97 @@ export const inspectSourceMapValue = ( leaves, }; }; + +/** + * Leaf maps reachable within the decoder's section depth and record budgets. + * Deeper or excess sections are dropped rather than expanded, so a map the + * decoder refused for its limits cannot allocate past them here. + */ +const boundedSourceMapLeaves = ( + root: unknown, + options: Pick, +): Record[] => { + const limits = options.limits ?? WEB_SOURCE_MAP_LIMITS; + const leaves: Record[] = []; + if ( + typeof root !== "object" || + root === null || + Array.isArray(root) || + (root as { version?: unknown }).version !== 3 + ) + return leaves; + const pending: { map: unknown; depth: number }[] = [{ map: root, depth: 0 }]; + let records = 0; + while (pending.length > 0) { + options.check?.(); + const current = pending.pop(); + if (current === undefined) break; + const sections = classifySourceMapSections(current.map); + if (sections.kind === "invalid") continue; + if (sections.kind === "regular") { + leaves.push(current.map as Record); + continue; + } + if (current.depth >= limits.sectionDepth) continue; + records += sections.sections.length; + if (records > limits.decodedRecords) break; + for (let index = sections.sections.length - 1; index >= 0; index -= 1) { + const section = classifySourceMapSection(sections.sections[index]); + if (section.kind === "embedded") + pending.push({ map: section.map, depth: current.depth + 1 }); + } + } + return leaves; +}; + +/** Recover observed source content without treating incomplete maps as absent. */ +export const inspectSourceMapSourceContent = ( + text: string, + options: Pick = {}, +): + | { readonly embedded: boolean; readonly complete: true } + | { + readonly embedded: boolean; + readonly complete: false; + readonly failure: SourceMapFormatFailure; + } => { + options.check?.(); + let value: unknown; + try { + value = JSON.parse(sourceMapJsonText(text)); + } catch (cause) { + if (!(cause instanceof SyntaxError)) throw cause; + return { + embedded: false, + complete: false, + failure: new SourceMapFormatFailure("format", cause.message), + }; + } + const hasContent = ( + inspection: ReturnType, + ): boolean => + inspection.declarations.some( + (declaration) => + declaration.content !== null && declaration.content.length > 0, + ); + try { + return { + embedded: hasContent(inspectSourceMapValue(value, options)), + complete: true, + }; + } catch (failure) { + if (!(failure instanceof SourceMapFormatFailure)) throw failure; + const budget = { records: 0 }; + let embedded = false; + for (const leaf of boundedSourceMapLeaves(value, options)) { + try { + const inspected = inspectSourceMapValue(leaf, { ...options, budget }); + embedded ||= hasContent(inspected); + } catch (cause) { + if (!(cause instanceof SourceMapFormatFailure)) throw cause; + // An unresolved leaf cannot discard content observed in valid siblings. + } + } + return { embedded, complete: false, failure }; + } +}; diff --git a/src/javascript/sourceMaps/TraceSourceMap.test.ts b/src/javascript/sourceMaps/TraceSourceMap.test.ts index 6620b22291..803965957b 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.test.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.test.ts @@ -35,6 +35,23 @@ it("classifies external indexed sections as unsupported with their exact URL", ( } }); +it.each([ + ["root field", { ...leaf, version: 2 }], + ["leaf field", { ...leaf, mappings: "!" }], + [ + "section field", + { version: 3, sections: [{ offset: { line: "0" }, map: leaf }] }, + ], +])("reports a %s schema rejection as a format failure", (_, map) => { + try { + inspectSourceMap(JSON.stringify(map)); + throw new Error("Expected schema rejection"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(SourceMapFormatFailure); + expect(error).toMatchObject({ reason: "format" }); + } +}); + it("uses the same conservative section classification for URL-plus-map and malformed URLs", () => { const externalUrl = "https://app.test/maps/part.map"; const external = () => diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index dab69c5fa1..a1ac3b8331 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -10,6 +10,8 @@ export class BinaryFormatFailure extends Error { constructor( readonly kind: "malformed" | "unsupported", message: string, + /** The image holds the metadata, in a form this reader cannot decode. */ + readonly undecodedContent = false, ) { super(message); this.name = "BinaryFormatFailure"; diff --git a/src/rust/RustAuditableReader.ts b/src/rust/RustAuditableReader.ts index 3e9b9d91e7..6b57e6c830 100644 --- a/src/rust/RustAuditableReader.ts +++ b/src/rust/RustAuditableReader.ts @@ -38,12 +38,15 @@ const documentSchema = documentVersionSchema /** Malformed or unsupported dependency-section bytes. */ export class RustAuditableFormatFailure extends Error { + /** The image holds the metadata, in a form this reader cannot decode. */ + readonly undecodedContent: boolean; constructor( readonly kind: "malformed" | "unsupported", message: string, - options?: ErrorOptions, + options?: ErrorOptions & { readonly undecodedContent?: boolean }, ) { super(message, options); + this.undecodedContent = options?.undecodedContent ?? false; this.name = "RustAuditableFormatFailure"; } } diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts index 33eaf910d5..fb8efa916f 100644 --- a/src/rust/RustBinaryImage.ts +++ b/src/rust/RustBinaryImage.ts @@ -95,6 +95,7 @@ const dependencySection = ( throw new RustAuditableFormatFailure( "unsupported", "Compressed dependency sections are unsupported", + { undecodedContent: true }, ); if (!section.file_backed) throw new RustAuditableFormatFailure( diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index c3d3ee7028..37817adad5 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -351,6 +351,7 @@ const failureForPrimary = ( return cause.kind === "unsupported" ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { cause, + undecodedContent: cause.undecodedContent, }) : new AnalysisInputError(OPERATION, { cause }, [ { diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 95aa20719e..04d5ef290a 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -25,6 +25,7 @@ import { registerAppleIpsTool } from "./registerAppleIpsTool.js"; import { registerKernelModuleTool } from "./registerKernelModuleTool.js"; import { registerKernelBuildConfigTool } from "./registerKernelBuildConfigTool.js"; import { registerPeLoadConfigTool } from "./registerPeLoadConfigTool.js"; +import { registerReleaseExposureTool } from "./registerReleaseExposureTool.js"; import { registerBinaryHardeningTool } from "./registerBinaryHardeningTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; import { registerPeImportsTool } from "./registerPeImportsTool.js"; @@ -233,6 +234,7 @@ export const createServer = ( peImports, windowsShortcut, electronFuses, + releaseExposure, pythonPyc, webNetworkCapture, javascriptRecovery, @@ -547,6 +549,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerReleaseExposureTool( + server, + releaseExposure, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerBinaryHardeningTool( server, binaryHardening, diff --git a/src/server/registerReleaseExposureTool.ts b/src/server/registerReleaseExposureTool.ts new file mode 100644 index 0000000000..ecba7e64d8 --- /dev/null +++ b/src/server/registerReleaseExposureTool.ts @@ -0,0 +1,31 @@ +import type { ReleaseExposureService } from "../application/releaseExposure/ReleaseExposureService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the read-only release-exposure audit and retain its Evidence. */ +export const registerReleaseExposureTool = ( + server: EvidenceMcpServer, + service: ReleaseExposureService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("audit_release_exposure"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/releaseExposureCli.test.ts b/tests/acceptance/releaseExposureCli.test.ts new file mode 100644 index 0000000000..d1c0b821a3 --- /dev/null +++ b/tests/acceptance/releaseExposureCli.test.ts @@ -0,0 +1,124 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { RELEASE_EXPOSURE_TOOL_CONTRACTS } from "../../src/contracts/releaseExposureToolContracts.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const PLANTED_SOURCE = "release-exposure-planted-secret-7c2e9a"; +const releaseExposureContract = RELEASE_EXPOSURE_TOOL_CONTRACTS.find( + (contract) => contract.name === "audit_release_exposure", +); +if (releaseExposureContract === undefined) + throw new Error("Missing release exposure contract"); +const outputSchema = releaseExposureContract.outputSchema; + +cliTest( + "exits 3 when a shipped source map embeds sources", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-release-exposure-cli-"); + await writeFile(join(root, "app.js"), "export const value = 1;\n"); + await writeFile( + join(root, "app.js.map"), + JSON.stringify({ + version: 3, + file: "app.js", + sources: ["app.js"], + names: [], + mappings: "", + sourcesContent: [`export const value = "${PLANTED_SOURCE}";\n`], + }), + ); + const clean = await cli.run({ + arguments: ["audit-release-exposure", root, "--json"], + }); + expect(clean.exitCode, clean.stderr).toBe(0); + const evidence = outputSchema.parse(clean.json); + const report = evidence.normalized_result; + expect( + report.checks.find( + (check) => check.id === "javascript.source_map_sources", + ), + ).toMatchObject({ status: "exposed", severity: "high" }); + expect( + report.checks.find((check) => check.id === "electron.run_as_node") + ?.status, + ).toBe("unknown"); + expect( + report.checks.find((check) => check.id === "native.pdb_path")?.status, + ).toBe("not_applicable"); + expect( + report.checks.find((check) => check.id === "go.build_paths")?.status, + ).toBe("not_applicable"); + expect(JSON.stringify(evidence)).not.toContain(PLANTED_SOURCE); + const gated = await cli.run({ + arguments: [ + "audit-release-exposure", + root, + "--fail_on", + "high", + "--json", + ], + }); + expect(gated.exitCode, gated.stderr).toBe(3); + }, + 60_000, +); + +cliTest( + "gates a marker in a text file outside the extension list", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-release-exposure-key-"); + await writeFile(join(root, "hello.txt"), "hello\n"); + await writeFile( + join(root, ".env.production"), + "-----BEGIN PRIVATE KEY-----\n", + ); + const result = await cli.run({ + arguments: [ + "audit-release-exposure", + root, + "--fail_on", + "high", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(3); + expect( + outputSchema + .parse(result.json) + .normalized_result.checks.find( + (check) => check.id === "text.credential_markers", + ), + ).toMatchObject({ + status: "exposed", + locations: [ + { kind: "artifact-path", path: join(root, ".env.production") }, + ], + }); + }, + 60_000, +); + +cliTest( + "keeps a clean directory at exit 0 when the gate is high", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-release-exposure-clean-"); + await writeFile(join(root, "hello.txt"), "hello\n"); + const result = await cli.run({ + arguments: [ + "audit-release-exposure", + root, + "--fail_on", + "high", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = outputSchema.parse(result.json).normalized_result; + expect(report.summary.no_exposure_observed_in_assessed_checks).toBe(true); + expect(report.summary.by_status.not_assessed).toBe(3); + }, + 60_000, +); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index df327bd945..2b4b29446e 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -257,6 +257,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_kernel_module", { path: nul }], ["inspect_kernel_build_config", { path: nul }], ["inspect_pe_load_config", { path: nul }], + ["audit_release_exposure", { path: nul }], ["inspect_binary_hardening", { path: nul }], ["inspect_python_pyc", { path: nul }], ["inspect_erlang_beam", { path: nul }], diff --git a/tests/boundary/filesystem/releaseExposure.test.ts b/tests/boundary/filesystem/releaseExposure.test.ts new file mode 100644 index 0000000000..24e10c0880 --- /dev/null +++ b/tests/boundary/filesystem/releaseExposure.test.ts @@ -0,0 +1,344 @@ +import { mkdir, symlink, truncate, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPackageWithOptions } from "@electron/asar"; +import { describe, expect, it } from "vitest"; + +import { + buildDenoSection, + denoElf, +} from "../../../src/artifacts/deno/DenoStandalone.fixture.js"; + +import { createReleaseExposureService } from "../../../src/composition/releaseExposure.js"; +import { DEFAULT_ASAR_MAX_HEADER_BYTES } from "../../../src/domain/analysisResourceLimits.js"; +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import { releaseExposureReportSchema } from "../../../src/domain/releaseExposure.js"; +import { prependAsarJsonWhitespace } from "../../fixtures/asarHeader.js"; +import { thinFuseImage } from "../../fixtures/electron/fuseImage.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("release exposure Deno filesystem boundary", () => { + it.each(["vfs", "remote"] as const)( + "audits Deno %s source maps using their retained roles", + async (kind) => { + const root = await createTestTempDirectory("rea-release-exposure-deno-"); + const path = join(root, "application"); + const script = Buffer.from("export const value = 1;\n"); + const map = Buffer.from( + JSON.stringify({ + version: 3, + sources: ["private.ts"], + sourcesContent: ["export const value: number = 1;"], + names: [], + mappings: "AAAA", + }), + ); + await writeFile( + path, + denoElf( + buildDenoSection( + kind === "remote" + ? { + specifiers: [{ text: "https://example.com/module", id: 1 }], + remoteModules: [ + { + specifierId: 1, + mediaType: 0, + data: `export const value = 1;\n//# sourceMappingURL=data:application/json;base64,${map.toString("base64")}`, + }, + ], + } + : { + vfs: [ + { + File: { + n: "main.ts", + o: [0, script.length], + s: [script.length, map.length], + u: true, + }, + }, + ], + vfsFileData: Buffer.concat([script, map]), + }, + ), + ), + ); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + const report = releaseExposureReportSchema.parse( + result.value.normalized_result, + ); + expect( + report.checks.find( + (check) => check.id === "javascript.source_map_sources", + )?.status, + ).toBe("exposed"); + } finally { + await service.close(); + } + }, + ); +}); + +describe("release exposure filesystem boundary", () => { + it("retains unknown JavaScript coverage for an external-section bundle.map", async () => { + const root = await createTestTempDirectory("rea-release-exposure-map-"); + await writeFile( + join(root, "bundle.map"), + JSON.stringify({ + version: 3, + sections: [{ offset: { line: 0, column: 0 }, url: "external.map" }], + }), + ); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path: root }); + if (!result.ok) throw result.error; + const report = releaseExposureReportSchema.parse( + result.value.normalized_result, + ); + for (const id of [ + "javascript.source_maps", + "javascript.source_map_sources", + ]) + expect(report.checks.find((check) => check.id === id)?.status).toBe( + "unknown", + ); + expect( + report.checks.find((check) => check.id === "native.debug_companions") + ?.status, + ).toBe("not_observed"); + } finally { + await service.close(); + } + }); + + it("audits an ASAR whose unpacked module was signed after packaging", async () => { + const root = await createTestTempDirectory("rea-release-exposure-signed-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "native.node"), "packaged module"); + const archive = join(root, "app.asar"); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); + await writeFile( + join(`${archive}.unpacked`, "native.node"), + "signed module", + ); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path: archive }); + if (!result.ok) throw result.error; + expect(result.value.limitations.join("\n")).toContain( + "integrity contradiction", + ); + expect( + releaseExposureReportSchema + .parse(result.value.normalized_result) + .checks.find((check) => check.id === "packaging.extractable_payload") + ?.status, + ).toBe("exposed"); + } finally { + await service.close(); + } + }); + + it("rejects a missing path and a symlink", async () => { + const root = await createTestTempDirectory("rea-release-exposure-files-"); + const path = join(root, "hello.txt"); + const alias = join(root, "alias.txt"); + await writeFile(path, "hello\n"); + await symlink(path, alias); + const service = createReleaseExposureService(); + try { + for (const [candidate, reason] of [ + [alias, "invalid_format"], + [join(root, "missing.txt"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "audit_release_exposure", + issues: [{ path: ["path"], reason, message: expect.any(String) }], + }); + } + } finally { + await service.close(); + } + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-release-exposure-budget-"); + const path = join(root, "blob.bin"); + await writeFile(path, Buffer.alloc(128, 1)); + const service = createReleaseExposureService(); + try { + const tooSmall = await service.inspect({ path, max_file_bytes: 64 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + } finally { + await service.close(); + } + }); + + it("reads a directory of plain text", async () => { + const root = await createTestTempDirectory("rea-release-exposure-dir-"); + await writeFile(join(root, "hello.txt"), "hello\n"); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path: root }); + if (!result.ok) throw result.error; + const report = result.value.normalized_result as { + summary: { + no_exposure_observed_in_assessed_checks: boolean; + by_status: { not_assessed: number }; + }; + }; + expect(report.summary.no_exposure_observed_in_assessed_checks).toBe(true); + expect(report.summary.by_status.not_assessed).toBe(3); + } finally { + await service.close(); + } + }); +}); + +describe("release exposure app bundle boundary", () => { + it("reports a sidecar's RunAsNode fuse in an app bundle with safe outer fuses", async () => { + const root = await createTestTempDirectory("rea-release-exposure-app-"); + const app = join(root, "Sample.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + const resources = join(app, "Contents", "Resources"); + await mkdir(framework, { recursive: true }); + await mkdir(resources, { recursive: true }); + const disabled = 0x30; + const enabled = 0x31; + await writeFile( + join(framework, "Electron Framework"), + thinFuseImage(Array.from({ length: 6 }, () => disabled)).bytes, + ); + const sidecar = join(resources, "sidecar"); + await writeFile( + sidecar, + thinFuseImage([enabled, disabled, disabled, disabled, disabled, disabled]) + .bytes, + ); + const service = createReleaseExposureService(); + try { + const result = await service.inspect({ path: app }); + if (!result.ok) throw result.error; + const check = releaseExposureReportSchema + .parse(result.value.normalized_result) + .checks.find((item) => item.id === "electron.run_as_node"); + expect(check?.status).toBe("exposed"); + expect(JSON.stringify(check?.locations)).toContain("sidecar"); + } finally { + await service.close(); + } + }); +}); + +describe("release exposure ASAR header admission", () => { + it("keeps a directory ASAR's header admission instead of the file-size ceiling", async () => { + const root = await createTestTempDirectory("rea-release-exposure-header-"); + const archive = join(root, "app.asar"); + const headerBytes = DEFAULT_ASAR_MAX_HEADER_BYTES + 4; + const json = Buffer.from("{}"); + const prefix = Buffer.alloc(16); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(headerBytes, 4); + prefix.writeUInt32LE(headerBytes - 4, 8); + prefix.writeUInt32LE(json.length, 12); + await writeFile(archive, Buffer.concat([prefix, json])); + // Declare the extent without allocating a header-sized buffer. + await truncate(archive, headerBytes + 8); + const service = createReleaseExposureService({}); + try { + const result = await service.inspect({ path: root }); + expect(result.ok).toBe(false); + if (result.ok) return; + const error = result.error; + expect(error).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(error instanceof AnalysisResourceConstraintError)) return; + expect(error.resource).not.toBe("file-size"); + expect(error.reportedLimits).not.toHaveProperty("max_file_bytes"); + expect(error.reportedLimits).toMatchObject({ + asar_max_header_bytes: DEFAULT_ASAR_MAX_HEADER_BYTES, + required_header_bytes: headerBytes, + }); + expect(error.reason).toContain("ASAR header/index"); + expect(error.fixedAllowance).toBeUndefined(); + expect(error.remediationAction).toContain("asar.max_header_bytes"); + expect(error.limit).toMatchObject({ + setting: "asar.max_header_bytes", + effective_value: DEFAULT_ASAR_MAX_HEADER_BYTES, + source: "default", + }); + } finally { + await service.close(); + } + }); + + it("reads a 20 MiB header under a 32 MiB setting and refuses it under a lower caller limit", async () => { + const root = await createTestTempDirectory("rea-release-exposure-wide-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "main.js"), "console.log('app');\n"); + const install = join(root, "install"); + await mkdir(install); + const archive = join(install, "app.asar"); + await createPackageWithOptions(source, archive, {}); + await prependAsarJsonWhitespace(archive, " ".repeat(20 * 1024 * 1024)); + const environment = { + REA_ASAR_MAX_HEADER_BYTES: String(32 * 1024 * 1024), + }; + + const admitted = createReleaseExposureService(environment); + try { + const result = await admitted.inspect({ path: install }); + if (!result.ok) throw result.error; + expect( + releaseExposureReportSchema + .parse(result.value.normalized_result) + .checks.find((check) => check.id === "packaging.extractable_payload") + ?.status, + ).toBe("exposed"); + } finally { + await admitted.close(); + } + + const lowered = createReleaseExposureService(environment, () => ({ + revision: 1, + overrides: { "asar.max_header_bytes": DEFAULT_ASAR_MAX_HEADER_BYTES }, + })); + try { + const result = await lowered.inspect({ path: install }); + if (result.ok) throw new Error("Expected ASAR header admission refusal"); + const error = result.error; + expect(error).toBeInstanceOf(AnalysisResourceConstraintError); + if (!(error instanceof AnalysisResourceConstraintError)) return; + expect(error.reportedLimits).toMatchObject({ + asar_max_header_bytes: DEFAULT_ASAR_MAX_HEADER_BYTES, + }); + expect(error.limit).toMatchObject({ + setting: "asar.max_header_bytes", + effective_value: DEFAULT_ASAR_MAX_HEADER_BYTES, + source: "runtime", + }); + } finally { + await lowered.close(); + } + }, 60_000); +}); diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index 3373eedd92..51eaeb9ab3 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -158,7 +158,10 @@ it("does not skip malformed earlier metadata to report a later valid record", () ); fixture.bytes[512 + 15] = 0xff; expect(() => readGoBinaryImage(fixture.bytes)).toThrow( - /unsupported format bit/, + expect.objectContaining({ + message: expect.stringMatching(/unsupported format bit/u), + undecodedContent: true, + }), ); }); diff --git a/tests/boundary/mcp/releaseExposureMcp.test.ts b/tests/boundary/mcp/releaseExposureMcp.test.ts new file mode 100644 index 0000000000..14228b979e --- /dev/null +++ b/tests/boundary/mcp/releaseExposureMcp.test.ts @@ -0,0 +1,75 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { RELEASE_EXPOSURE_TOOL_CONTRACTS } from "../../../src/contracts/releaseExposureToolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const PLANTED_SOURCE = "release-exposure-planted-secret-7c2e9a"; +const releaseExposureContract = RELEASE_EXPOSURE_TOOL_CONTRACTS.find( + (contract) => contract.name === "audit_release_exposure", +); +if (releaseExposureContract === undefined) + throw new Error("Missing release exposure contract"); +const outputSchema = releaseExposureContract.outputSchema; + +mcpTest( + "advertises and reads release exposure without changing the artifact", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("Must not start a deep provider"); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + try { + const definition = (await client.listTools()).tools.find( + (tool) => tool.name === "audit_release_exposure", + ); + if (definition === undefined) + throw new Error("Missing advertised contract"); + expect(definition.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-release-exposure-mcp-"); + await writeFile( + join(root, "app.js.map"), + JSON.stringify({ + version: 3, + file: "app.js", + sources: ["app.js"], + names: [], + mappings: "", + sourcesContent: [PLANTED_SOURCE], + }), + ); + const response = await client.callTool( + { name: "audit_release_exposure", arguments: { path: root } }, + { toolDefinition: definition }, + ); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = outputSchema.parse(response.structuredContent); + expect( + evidence.normalized_result.checks.find( + (check) => check.id === "javascript.source_map_sources", + )?.status, + ).toBe("exposed"); + expect(JSON.stringify(evidence)).not.toContain(PLANTED_SOURCE); + const bad = await client.callTool( + { + name: "audit_release_exposure", + arguments: { path: "relative-dir" }, + }, + { toolDefinition: definition }, + ); + expect(bad.isError).toBe(true); + } finally { + await session.close(); + } + }, + 60_000, +); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 445ab97677..e42cc830f5 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -1,4 +1,5 @@ import { PeLoadConfigProvider } from "../../src/native/pe/PeLoadConfigProvider.js"; +import { ReleaseExposureReader } from "../../src/artifacts/ReleaseExposureReader.js"; import { BinaryHardeningProvider } from "../../src/native/hardening/BinaryHardeningProvider.js"; import { inspectPeLoadConfigInputSchema } from "../../src/domain/native/peLoadConfig.js"; import { inspectBinaryHardeningInputSchema } from "../../src/domain/native/binaryHardening.js"; @@ -1013,6 +1014,7 @@ const stableArtifactProviders = [ ["kernel build config", () => new KernelBuildConfigProvider()], ["PE certificates", () => new PeCertificatesProvider()], ["PE load configuration", () => new PeLoadConfigProvider()], + ["release exposure", () => new ReleaseExposureReader()], ["binary hardening", () => new BinaryHardeningProvider()], ["PE imports", () => new PeImportsProvider()], ["Windows shortcut", () => new WindowsShortcutProvider()], @@ -1106,6 +1108,8 @@ const inspectFileFailure = ( inspectPeResourcesInputSchema.parse({ path }), options, ); + if (provider instanceof ReleaseExposureReader) + return provider.inspect({ path }, options, () => undefined); return provider.inspect({ path }, options); };