diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 66cbefbb1a..663ac1110f 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -325,6 +325,16 @@ explicit path or endpoint and do not need it. zstd containers are unsupported; decompress into a separate file with an external codec utility, then inspect the decompressed `ET_REL` image. See the [kernel module guide](https://github.com/morluto/rea/blob/main/docs/kernel-modules.md). +- Linux kernel version and build config (`CONFIG_*` options such as + `CONFIG_SECURITY_SELINUX`): `inspect_kernel_build_config` with the explicit + local `vmlinux`, raw `Image`, x86 `bzImage`, or `configs.ko`. This built-in + target-free inspection requires no Linux host or `extract-ikconfig`. Preserve + the banner version, config SHA-256, option count, and value kinds; use `name` + for one option. An `IKCFG_ST` string is a config only with a valid gzip member + and `IKCFG_ED`. An absent config may mean the kernel was built without + `CONFIG_IKCONFIG`. Non-gzip payloads are unsupported; decompress externally, + then inspect the result. See the + [kernel build config guide](https://github.com/morluto/rea/blob/main/docs/guides/native/kernel-build-config.md). - CPython 3.7–3.13 `.pyc` header, nested marshal code objects and bytecode identity: `inspect_python_pyc` with the explicit local file. This built-in target-free inspection requires no Python installation. Preserve magic, 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 7c422ba9c2..f95b378e2f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -230,6 +230,27 @@ container. gzip, xz, and zstd whole-file containers are unsupported and name decompress into a separate file, then call `inspect_kernel_module` on that file. See [Linux kernel modules](https://github.com/morluto/rea/blob/main/docs/kernel-modules.md). +## Linux kernel build config + +Use `inspect_kernel_build_config` for an explicit local kernel image (`vmlinux` +ELF, raw arm64 `Image`, x86 `bzImage`/`zImage`, or `configs.ko`) when the +question is which kernel version it is or how it was configured. No +`open_binary`, Linux host, or `extract-ikconfig` is required. The CLI +equivalent is `inspect-kernel-build-config PATH`; pass `name` for one exact +`CONFIG_*` option and `offset`/`limit` to page the rest. Preserve the banner +version, builder, and compiler, the config SHA-256 and option count, and each +entry's value kind (`y`, `m`, `string`, `number`, `not_set`). A config is +accepted only when `IKCFG_ST` is immediately followed by a valid gzip member +and `IKCFG_ED`, so an ordinary `IKCFG_ST` string is not a config. Offsets with +`space: "inflated_payload"` are positions in an inflated bzImage payload, not +file offsets. An absent config describes only the searched bytes; the kernel +may have been built without `CONFIG_IKCONFIG` or with it as a module. Truncated +payloads are incomplete, not absent. A framed blob that does not decode, has a +bad CRC, or holds non-config text is `malformed`, not absent. xz, lz4, lzma, zstd, bzip2, and lzo +payloads are unsupported and name `external_decompression`: decompress with an +external utility, then call `inspect_kernel_build_config` on the result. See +[Linux kernel build config](https://github.com/morluto/rea/blob/main/docs/guides/native/kernel-build-config.md). + ## CPython pyc files Use `inspect_python_pyc` for an explicit local CPython 3.7–3.13 `.pyc` when the diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index f9a541b7a2..e883a8b7ba 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -43,6 +43,11 @@ export const documentationSections = [ source: "guides/native/binary-diagnostics.md", route: "/binary-diagnostics", }, + { + text: "Linux kernel build config", + source: "guides/native/kernel-build-config.md", + route: "/kernel-build-config", + }, { text: "Recorded crash evidence", source: "guides/native/recorded-crashes.md", diff --git a/docs/README.md b/docs/README.md index 8cdc839916..7bfe41dbf5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ product pages. The VitePress documentation source is this `docs/` tree. - [Native binary investigation](guides/native/native-investigation.md) - [Go binary build metadata](guides/native/go-binaries.md) - [Offline binary diagnostics](guides/native/binary-diagnostics.md) +- [Linux kernel build config](guides/native/kernel-build-config.md) - [Recorded crash evidence](guides/native/recorded-crashes.md) - [Windows Shell Link shortcuts](guides/native/windows-shortcut.md) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index e1406359d6..3ae3a0b0dd 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -489,6 +489,22 @@ applicable. gzip, xz, and zstd containers are unsupported and name utility, then inspect the decompressed file. See [Linux kernel modules](../kernel-modules.md). +Read a Linux kernel image's version banner and embedded `CONFIG_IKCONFIG` +build config without executing it: + +```bash +rea inspect-kernel-build-config /absolute/path/to/vmlinux --json +rea inspect-kernel-build-config /absolute/path/to/bzImage --name CONFIG_MODULE_SIG --json +``` + +The workflow reports the banner version, builder, and compiler, the config +option count, and paged `CONFIG_*` entries (`y`, `m`, strings, numbers, and +`not_set`). An `IKCFG_ST` string counts as a config only when a valid gzip +member follows it and `IKCFG_ED` follows that member. A gzip bzImage payload +is inflated and searched; other payload codecs are unsupported and name +`external_decompression` as the next probe. See +[Linux kernel build config](../guides/native/kernel-build-config.md). + Inspect a CPython 3.7–3.13 `.pyc` header and nested marshal code objects (3.14 and 3.15 headers are qualified but their marshal layout is not pinned) without executing bytecode or installing Python: diff --git a/docs/guides/native/kernel-build-config.md b/docs/guides/native/kernel-build-config.md new file mode 100644 index 0000000000..bbc7b5052a --- /dev/null +++ b/docs/guides/native/kernel-build-config.md @@ -0,0 +1,151 @@ +# Linux kernel build config + +`inspect_kernel_build_config` / `inspect-kernel-build-config` reads the +`Linux version` banner and the embedded `.config` from one selected local +kernel image without executing or booting it. Accepted inputs are a `vmlinux` +ELF, a raw arm64 `Image`, an x86 `bzImage` or `zImage` whose payload is gzip, +and a `configs.ko` module. It does not require a Linux host, +`extract-ikconfig`, or `/proc/config.gz`. + +```bash +rea inspect-kernel-build-config /absolute/path/to/vmlinux --json +rea inspect-kernel-build-config /absolute/path/to/bzImage --name CONFIG_SECURITY_SELINUX --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_kernel_build_config", + "arguments": { "path": "/artifacts/vmlinux", "name": "CONFIG_MODULE_SIG" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte +length. `format` is `elf` when the file starts with the ELF magic and `file` +otherwise. + +`banner` is the first printable string that starts with `Linux version ` +followed by a version token beginning with a digit and ends at a NUL or +newline within 512 bytes. `version` is that token. `builder` is the first +parenthesized group (`user@host`). `compiler` is the second group up to the +last `) #`, so nested parentheses in compiler strings are kept. The banner is +`absent` when no such string was found in the searched bytes and `unknown` +when the bytes were not searched. + +`config` is `present` only when all of the following hold: + +- `IKCFG_ST` is immediately followed by a gzip member (`1f 8b 08`). +- The member inflates within `max_config_bytes` and its CRC-32 and ISIZE + trailer match. +- The member trailer ends exactly at the first `IKCFG_ED` after the gzip + header. Inflation reads only the bytes up to that marker. +- Every non-blank line of the inflated text is a `#` comment, + `# CONFIG_X is not set`, or `CONFIG_X=value`, and the text is valid UTF-8 + with no control bytes other than tab, CR, and LF. + +An `IKCFG_ST` string that is not followed by gzip magic, or whose complete +member is not followed by `IKCFG_ED`, is skipped, so an ordinary string is never +reported as a config. When `IKCFG_ST`, gzip magic, and a later `IKCFG_ED` frame +a member that does not decode, a deflate stream cut off by that marker, a +member that fails its CRC-32 or ISIZE check, or text that fails the grammar, +the config is `malformed` with coverage +`incomplete`, and `marker`, `gzip`, and `end_marker` locate the blob. A +malformed blob is never reported as an absent config. A valid config later in +the searched bytes still wins. `marker`, `gzip`, and `end_marker` give +the three ranges. `sha256` hashes the inflated text. `option_count` counts +every `CONFIG_*` line. `matched_count` counts the options that match the +optional exact `name` filter, and `entries` is the `offset`/`limit` page of +those matches in file order. + +Each entry has a `name`, a `kind`, and a `value`: + +| `kind` | Source line | `value` | +| --------- | -------------------------- | ------------------------- | +| `y`, `m` | `CONFIG_X=y`, `CONFIG_X=m` | `y` or `m` | +| `number` | decimal or `0x` hex | the text as written | +| `string` | `CONFIG_X="..."` | decoded text (`\"`, `\\`) | +| `not_set` | `# CONFIG_X is not set` | `null` | + +Banner text and configuration values are target-derived and untrusted. + +## bzImage payloads + +A file with `HdrS` at offset `0x202` is an x86 setup header. From boot +protocol 2.08, `payload_offset` and `payload_length` locate the compressed +kernel after the `(setup_sects + 1) * 512` setup code. `payload` reports the +container, codec, file range, and inflated length. A gzip payload is inflated +within `max_payload_bytes` and the inflated bytes are searched. Locations in +those bytes have `space: "inflated_payload"` and are offsets in the inflated +payload, not file offsets. Only the leading gzip member is inflated, and its +own ISIZE trailer gives the inflated length. Kbuild appends a separate 4-byte +size only for the non-gzip codecs. Any bytes after the gzip member inside the +payload are not read. + +xz, lz4, lzma, zstd, bzip2, and lzo payloads are `unsupported`. The result +names the codec and `external_decompression` as the next probe. This is an +external step, not a REA operation: decompress the payload with the matching +utility into a separate file, then call `inspect_kernel_build_config` on that +file. A file that is itself a compressed stream, including a whole-file gzip +such as `Image.gz`, is reported as `unsupported` with its codec and is not +inflated. Only a gzip payload inside a bzImage is inflated. + +## Limits and failures + +The selected file is limited to 64 MiB by default (512 MiB maximum). An IKCFG +member may inflate to 4 MiB by default (32 MiB maximum) and a bzImage payload +to 128 MiB by default (512 MiB maximum). At most 16384 `CONFIG_*` options are +examined by default (65536 maximum). A page holds 256 entries by default +(4096 maximum). Exceeding any budget fails with no partial success and names +the option to raise. An IKCFG member past `max_config_bytes` fails that way only +when no later candidate is accepted, so an oversized decoy cannot hide a valid +config. + +One search inflates at most 16 `IKCFG_ST` gzip candidates and at most four +times `max_config_bytes` of output across them. A candidate whose stream fails +partway through is charged its full per-candidate allowance, since its partial +output is not measured. When that search budget runs +out before a config is accepted, the config is `incomplete` with reason +`config_search_exhausted` instead of failing or reporting an absent config. +Inflation runs off the event loop. Every scan, including marker +searches that find nothing, config text checks, validation of one long value, +and hashing, yields to it at least once +per MiB, so a cancellation that arrives while the image is being parsed is honored +at the next check. One inflate call runs to its output bound once started. + +Results that could not search or read the kernel bytes stay `incomplete`, +`malformed`, or `unsupported` and are never +reported as an absent config: + +| `config.reason` | Meaning | +| ---------------------------------- | ---------------------------------------------------------------------------- | +| `config_gzip_invalid` | a framed `IKCFG_ST` gzip member does not decode or fails its CRC-32 or ISIZE | +| `config_text_invalid` | a framed `IKCFG_ST` gzip member is not `.config` text | +| `config_gzip_truncated` | an `IKCFG_ST` gzip member runs past the searched bytes | +| `config_search_exhausted` | decoy candidates used up the config search budget | +| `payload_truncated` | the declared bzImage payload or its gzip member ends early | +| `payload_gzip_invalid` | the bzImage gzip payload fails to inflate or its trailer | +| `payload_codec_unsupported` | a compressed payload or whole-file stream was not inflated | +| `unrecognized_payload_codec` | the bzImage payload starts with no known codec magic | +| `setup_header_payload_unavailable` | the setup header predates 2.08 or has no payload length | + +A kernel built without `CONFIG_IKCONFIG` carries no embedded config, so an +`absent` config describes only the bytes that were searched. A kernel built +with `CONFIG_IKCONFIG=m` keeps the config in `configs.ko`; inspect that module +instead of the image. This workflow does not execute the image, compare the +config with a source tree, or evaluate Kconfig dependencies. + +## Format references + +The markers follow Linux v6.12's +[`kernel/configs.c`](https://github.com/torvalds/linux/blob/v6.12/kernel/configs.c) +and +[`scripts/extract-ikconfig`](https://github.com/torvalds/linux/blob/v6.12/scripts/extract-ikconfig). +The setup header fields follow the +[x86 boot protocol](https://docs.kernel.org/arch/x86/boot.html). The banner +format follows +[`init/version-timestamp.c`](https://github.com/torvalds/linux/blob/v6.12/init/version-timestamp.c). +These references establish the decoded layout, not runtime behavior. diff --git a/docs/index.md b/docs/index.md index 660d11ce5a..16020670f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ The [documentation directory](README.md) lists every guide by domain. | Task | Guides | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| 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), [Linux kernel modules](kernel-modules.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) | +| 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), [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) | diff --git a/docs/specs/README.md b/docs/specs/README.md index 0efb22189f..fbff89e315 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,3 +15,5 @@ for the specification lifecycle, roadmap granularity and producer-owned contract - [Go static string-header literal recovery](native/go-string-literals/spec.md) ([plan](native/go-string-literals/plan.md), [tasks](native/go-string-literals/tasks.md)) +- [Linux kernel build config](native/kernel-build-config/spec.md) + ([plan](native/kernel-build-config/plan.md), [tasks](native/kernel-build-config/tasks.md)) diff --git a/docs/specs/native/kernel-build-config/plan.md b/docs/specs/native/kernel-build-config/plan.md new file mode 100644 index 0000000000..cffee82882 --- /dev/null +++ b/docs/specs/native/kernel-build-config/plan.md @@ -0,0 +1,34 @@ +# Implementation plan + +## Boundary and contract + +`src/domain/native/kernelBuildConfig.ts` owns the input and result schemas, +reasons, limits and limitations. `src/native/elf/KernelBuildConfig.ts` parses +the banner, the IKCFG framing and the bzImage setup header. +`src/native/elf/KernelCompressionCodec.ts` owns compression magic detection and +is shared with `KernelModuleParser.ts`, so the two parsers name codecs the same +way. `src/native/elf/KernelBuildConfigProvider.ts` reads the file through the +stable artifact read and passes the cancellation signal to the parser. + +`src/application/binaryDiagnostics/KernelBuildConfigService.ts` depends on the +domain and `KernelBuildConfigPort`. It is composed in +`src/composition/binaryDiagnostics.ts` and registered through +`capabilityDeclarations.ts`, `capabilityServices.ts` and `createServer.ts`, +beside the sibling binary diagnostics tools. + +## Incremental work and validation + +1. Define the schemas, reasons and limits, with domain tests. +2. Parse the banner and IKCFG framing over synthetic fixtures, including + malformed, truncated and decoy members. +3. Add bzImage payload handling and the shared codec detection. +4. Add search budgets and cancellation, with tests for each. +5. Register the CLI command and MCP tool, then add acceptance, filesystem and + MCP boundary tests. +6. Write the guide and the CLI reference entry, update the skill reference, + and run focused tests, lint, typecheck and docs checks. + +## Effects and compatibility + +Additive: one new read-only tool and CLI command. The kernel module parser's +codec detection moves to the shared owner with no change in its output. diff --git a/docs/specs/native/kernel-build-config/spec.md b/docs/specs/native/kernel-build-config/spec.md new file mode 100644 index 0000000000..49f8d3523b --- /dev/null +++ b/docs/specs/native/kernel-build-config/spec.md @@ -0,0 +1,75 @@ +# Linux kernel build config + +## Problem and outcome + +Analysing a Linux kernel image usually starts with two questions: which kernel +version is this, and how was it configured. A kernel built with +`CONFIG_IKCONFIG` embeds its `.config` as a gzip blob between `IKCFG_ST` and +`IKCFG_ED`. REA reports kernel modules and ELF notes, but it cannot read the +version banner or the embedded config, so an agent needs a Linux host and +`scripts/extract-ikconfig`. + +This feature adds `inspect_kernel_build_config` and +`inspect-kernel-build-config`. They read one selected local kernel image and +report its `Linux version` banner and embedded build config, without +executing or booting it. + +[Linux kernel build config](../../../guides/native/kernel-build-config.md) describes the +current behavior and limitations. This directory is the feature +specification, implementation plan and task list. The guide does not replace +these files. + +## Requirements + +1. Accepted inputs are a `vmlinux` ELF, a raw arm64 `Image`, an x86 `bzImage` + or `zImage` whose payload is gzip, and a `configs.ko` module. The file is + read through the stable artifact read and never executed. +2. The banner is the first printable `Linux version ` string followed by a + version token that starts with a digit and ends at a NUL or newline within + 512 bytes. Version, builder and compiler are split from it. The scan is + linear in the searched bytes. +3. A config is `present` only when `IKCFG_ST` is followed by a gzip member that + ends exactly at the next `IKCFG_ED`, inflates within `max_config_bytes`, + passes its CRC-32 and ISIZE checks, and parses as `.config` text. Inflation + reads only the bytes up to that marker. +4. Framing that fails its checks or grammar, including a framed deflate stream + that does not decode or is cut off by `IKCFG_ED`, is `malformed`, never + `absent`. An unframed `IKCFG_ST` string is skipped. A valid config later in the bytes + still wins. +5. Entries report name, kind (`y`, `m`, `number`, `string`, `not_set`) and + value, with an exact `name` filter and `offset`/`limit` paging. +6. A bzImage setup header from boot protocol 2.08 locates the payload. A gzip + payload is inflated within `max_payload_bytes` and searched, with locations + marked `inflated_payload`. Other payload codecs, and a file that is itself a + compressed stream such as `Image.gz`, are `unsupported` and name the codec, + using the codec detection shared with the kernel module parser. +7. Budgets bound file size, config and payload inflate size, option count and + page size. The config search inflates at most 16 candidates and at most + four times `max_config_bytes` in total, charging a stream that fails + partway through its full per-candidate allowance. Exhausting the search budget is + `incomplete` with `config_search_exhausted`. A member past + `max_config_bytes` fails the budget only when no later candidate is + accepted. +8. Cancellation is checked between candidates, between banner hits, while + scanning long text and while parsing config lines. Inflation runs off the + event loop and each scan, including marker searches that find nothing, text + checks, validation of a single long value and hashing, yields to it at least once per MiB, so a live + cancellation reaches those checks. +9. Banner text and config values are marked target-derived and untrusted. +10. MCP requires an absolute path. The CLI resolves relative paths against the + working directory. + +## Acceptance + +- Unit tests cover banner parsing, config framing, grammar, malformed and + truncated members, decoy candidates, search budgets, cancellation, and + bzImage payload handling. +- Service tests cover each status and reason. +- CLI acceptance, filesystem boundary and MCP boundary tests exercise the + public contract. + +## Non-goals + +- Decompressing xz, lz4, lzma, zstd, bzip2 or lzo payloads. +- Comparing the config with a source tree or evaluating Kconfig dependencies. +- Executing or booting the image. diff --git a/docs/specs/native/kernel-build-config/tasks.md b/docs/specs/native/kernel-build-config/tasks.md new file mode 100644 index 0000000000..a195392304 --- /dev/null +++ b/docs/specs/native/kernel-build-config/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Define the single-feature scope, contract and implementation plan. +- [x] Parse the banner and IKCFG framing with unit tests. +- [x] Inflate gzip bzImage payloads and share codec detection with the kernel module parser. +- [x] Bound the config search and honor cancellation. +- [x] Register the CLI command and MCP tool with acceptance and boundary tests. +- [x] Document the guide, CLI reference and skill reference. +- [x] Verify against a real distribution kernel (Alpine 6.12 linux-virt bzImage and its `configs.ko`). diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a04df038f4..b81ee8446e 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -632,6 +632,7 @@ const workflowAvailabilityFor = ( name === "inspect_elf_notes" || name === "inspect_devicetree" || name === "inspect_kernel_module" || + name === "inspect_kernel_build_config" || name === "inspect_pe_load_config" || name === "inspect_binary_hardening" ) diff --git a/src/application/binaryDiagnostics/KernelBuildConfigPort.ts b/src/application/binaryDiagnostics/KernelBuildConfigPort.ts new file mode 100644 index 0000000000..171a5ce312 --- /dev/null +++ b/src/application/binaryDiagnostics/KernelBuildConfigPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectKernelBuildConfigInput, + KernelBuildConfig, +} from "../../domain/native/kernelBuildConfig.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and kernel build-config decoding, independent of a disassembler session. */ +export interface KernelBuildConfigPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectKernelBuildConfigInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/KernelBuildConfigService.test.ts b/src/application/binaryDiagnostics/KernelBuildConfigService.test.ts new file mode 100644 index 0000000000..5dec5fa3a6 --- /dev/null +++ b/src/application/binaryDiagnostics/KernelBuildConfigService.test.ts @@ -0,0 +1,188 @@ +import { expect, it } from "vitest"; +import { KernelBuildConfigService } from "./KernelBuildConfigService.js"; +import { KERNEL_BUILD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js"; +import { + inspectKernelBuildConfigInputSchema, + type KernelBuildConfig, +} from "../../domain/native/kernelBuildConfig.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { + badCrcKernelConfigFixture, + bzImageGzipFixture, + bzImageXzFixture, + kernelBuildConfigFixture, + longStringKernelConfigFixture, + nonConfigGzipFixture, + plainIkcfgStringFixture, + truncatedKernelConfigFixture, +} from "../../native/elf/KernelBuildConfig.fixture.js"; +import { parseKernelBuildConfig } from "../../native/elf/KernelBuildConfig.js"; + +const identity = KERNEL_BUILD_CONFIG_PROVIDER; +const path = "/artifacts/vmlinux"; + +const reportOf = ( + bytes: Buffer, + extra: object = {}, +): Promise => + parseKernelBuildConfig( + bytes, + { path, sha256: "a".repeat(64), bytes: bytes.length }, + inspectKernelBuildConfigInputSchema.parse({ path, ...extra }), + ); + +const serviceFor = (report: KernelBuildConfig) => + new KernelBuildConfigService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }); + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new KernelBuildConfigService({ + identity, + inspect: () => { + calls += 1; + return reportOf(kernelBuildConfigFixture()).then(ok); + }, + }); + const relative = await service.inspect({ path: "vmlinux" }); + const extra = await service.inspect({ path, extra: true }); + const badName = await service.inspect({ path, name: "SELINUX" }); + for (const result of [relative, extra, badName]) { + if (result.ok) throw new Error("Expected input rejection"); + expect(result.error).toBeInstanceOf(AnalysisInputError); + } + expect(calls).toBe(0); +}); + +it.each([ + ["vmlinux", () => reportOf(kernelBuildConfigFixture()), "elf"], + ["plain IKCFG_ST", () => reportOf(plainIkcfgStringFixture()), "elf"], + ["malformed IKCFG text", () => reportOf(nonConfigGzipFixture()), "elf"], + ["bad IKCFG CRC", () => reportOf(badCrcKernelConfigFixture()), "elf"], + ["long string value", () => reportOf(longStringKernelConfigFixture()), "elf"], + ["bzImage", () => reportOf(bzImageGzipFixture()), "file"], + ["xz bzImage", () => reportOf(bzImageXzFixture()), "file"], +] as const)( + "records parser output for %s as observed Evidence", + async (_label, make, format) => { + const report = await make(); + const result = await serviceFor(report).inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format }, + normalized_result: report, + }); + }, +); + +it("records file ranges only for file-space locations", async () => { + const report = await reportOf(bzImageGzipFixture()); + const result = await serviceFor(report).inspect({ path }); + if (!result.ok) throw result.error; + const ranges = result.value.locations?.filter( + (location) => location.kind === "file-offset-range", + ); + expect(ranges).toEqual([ + { + kind: "file-offset-range", + start: report.payload.offset, + end: (report.payload.offset ?? 0) + (report.payload.bytes ?? 0), + }, + ]); +}); + +it.each([ + [ + "a config option count that disagrees with examined entries", + (report: KernelBuildConfig): KernelBuildConfig => ({ + ...report, + coverage: { ...report.coverage, examined_entries: 1 }, + }), + ], + [ + "a gzip range that does not follow its marker", + (report: KernelBuildConfig): KernelBuildConfig => ({ + ...report, + config: { + ...report.config, + gzip: + report.config.gzip === null + ? null + : { ...report.config.gzip, offset: report.config.gzip.offset + 1 }, + }, + }), + ], + [ + "an unsupported payload without a next probe", + (report: KernelBuildConfig): KernelBuildConfig => ({ + ...report, + payload: { ...report.payload, status: "unsupported", codec: "xz" }, + }), + ], + [ + "a banner whose text does not carry its version", + (report: KernelBuildConfig): KernelBuildConfig => ({ + ...report, + banner: { ...report.banner, version: "9.9" }, + }), + ], + [ + "a malformed config reported with complete coverage", + async (): Promise => { + const malformed = await reportOf(nonConfigGzipFixture()); + return { + ...malformed, + coverage: { ...malformed.coverage, status: "complete" }, + }; + }, + ], + [ + "an incomplete config carrying a malformed reason", + async (): Promise => { + const truncated = await reportOf(truncatedKernelConfigFixture()); + return { + ...truncated, + config: { ...truncated.config, reason: "config_text_invalid" }, + }; + }, + ], +] as const)("rejects %s", async (_label, mutate) => { + const result = await serviceFor( + await mutate(await reportOf(kernelBuildConfigFixture())), + ).inspect({ path }); + if (result.ok) throw new Error("Expected malformed provider output"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("rejects a report whose page disagrees with the caller page", async () => { + const result = await serviceFor( + await reportOf(kernelBuildConfigFixture()), + ).inspect({ path, limit: 1 }); + if (result.ok) throw new Error("Expected malformed provider output"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("does not call the provider after early cancellation", async () => { + const service = new KernelBuildConfigService({ + identity, + inspect: () => { + throw new Error( + "An already cancelled request must not reach its provider", + ); + }, + }); + const result = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/binaryDiagnostics/KernelBuildConfigService.ts b/src/application/binaryDiagnostics/KernelBuildConfigService.ts new file mode 100644 index 0000000000..57e0597fae --- /dev/null +++ b/src/application/binaryDiagnostics/KernelBuildConfigService.ts @@ -0,0 +1,323 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { KernelBuildConfigPort } from "./KernelBuildConfigPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectKernelBuildConfigInputSchema, + kernelBuildConfigSchema, + KERNEL_BUILD_CONFIG_MALFORMED_REASONS, + KERNEL_BUILD_CONFIG_MARKER_BYTES, + KERNEL_BUILD_CONFIG_NUMBER, + type InspectKernelBuildConfigInput, + type KernelBuildConfig, + type KernelBuildConfigEntry, +} from "../../domain/native/kernelBuildConfig.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_kernel_build_config"; +const MARKER_BYTES = KERNEL_BUILD_CONFIG_MARKER_BYTES; + +type Range = KernelBuildConfig["config"]["marker"]; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class KernelBuildConfigService { + constructor(readonly provider: KernelBuildConfigPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectKernelBuildConfigInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = kernelBuildConfigSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport(report.data, input.value) + ) + return err( + new AnalysisOutputError( + OPERATION, + "Kernel build config provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: value.format === "elf" ? "elf" : "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: locationsOf(value), + }, + ), + options?.signal, + ); + } +} + +const fits = ( + length: number, + range: { offset: number; bytes: number }, +): boolean => range.bytes <= length && range.offset <= length - range.bytes; + +const fileRanges = ( + report: KernelBuildConfig, +): { offset: number; bytes: number }[] => { + const found: { offset: number; bytes: number }[] = []; + const add = (range: Range): void => { + if (range !== null && range.space === "file") found.push(range); + }; + if (report.payload.offset !== null && report.payload.bytes !== null) + found.push({ offset: report.payload.offset, bytes: report.payload.bytes }); + add(report.banner.location); + add(report.config.marker); + add(report.config.gzip); + add(report.config.end_marker); + return found; +}; + +const locationsOf = (report: KernelBuildConfig) => [ + { kind: "artifact-path" as const, path: report.artifact.path }, + ...fileRanges(report).map((range) => ({ + kind: "file-offset-range" as const, + start: range.offset, + end: range.offset + range.bytes, + })), +]; + +const valueAgrees = (entry: KernelBuildConfigEntry): boolean => { + if (entry.trust !== "target-derived-untrusted") return false; + if (entry.kind === "y" || entry.kind === "m") + return entry.value === entry.kind; + if (entry.kind === "not_set") return entry.value === null; + if (entry.kind === "number") + return entry.value !== null && KERNEL_BUILD_CONFIG_NUMBER.test(entry.value); + return entry.value !== null; +}; + +const markersAgree = (config: KernelBuildConfig["config"]): boolean => { + const { marker, gzip, end_marker } = config; + if (marker === null || gzip === null || end_marker === null) return false; + return ( + marker.bytes === MARKER_BYTES && + end_marker.bytes === MARKER_BYTES && + marker.space === gzip.space && + gzip.space === end_marker.space && + gzip.offset === marker.offset + MARKER_BYTES && + end_marker.offset === gzip.offset + gzip.bytes + ); +}; + +const rangesAgree = (report: KernelBuildConfig): boolean => { + if (!fileRanges(report).every((range) => fits(report.artifact.bytes, range))) + return false; + const inflated = report.payload.inflated_bytes; + const inflatedRanges = [ + report.banner.location, + report.config.marker, + report.config.gzip, + report.config.end_marker, + ].filter((range) => range !== null && range.space === "inflated_payload"); + if (inflated === null) return inflatedRanges.length === 0; + return inflatedRanges.every( + (range) => range !== null && fits(inflated, range), + ); +}; + +const bannerAgrees = (banner: KernelBuildConfig["banner"]): boolean => { + if (banner.trust !== "target-derived-untrusted") return false; + if (banner.status === "present") { + if ( + banner.text === null || + banner.version === null || + banner.location === null || + banner.location.bytes !== banner.text.length || + !banner.text.startsWith(`Linux version ${banner.version}`) + ) + return false; + return /^[0-9][0-9A-Za-z.+_~+-]*$/u.test(banner.version); + } + return ( + banner.text === null && + banner.version === null && + banner.builder === null && + banner.compiler === null && + banner.location === null + ); +}; + +const configCountsAgree = (config: KernelBuildConfig["config"]): boolean => { + const page = Math.min( + config.limit, + Math.max(0, config.matched_count - config.offset), + ); + if (config.entries.length !== page) return false; + if (config.matched_count > config.option_count) return false; + if (config.name === null && config.matched_count !== config.option_count) + return false; + if ( + config.name !== null && + config.entries.some((entry) => entry.name !== config.name) + ) + return false; + if (config.status === "present") + return ( + config.reason === null && + config.sha256 !== null && + config.option_count >= 1 && + markersAgree(config) + ); + const empty = + config.option_count === 0 && + config.matched_count === 0 && + config.entries.length === 0 && + config.sha256 === null; + const malformedReason = + config.reason !== null && + (KERNEL_BUILD_CONFIG_MALFORMED_REASONS as readonly string[]).includes( + config.reason, + ); + if (config.status === "malformed") + return empty && malformedReason && markersAgree(config); + return ( + empty && + !malformedReason && + config.marker === null && + config.gzip === null && + config.end_marker === null && + (config.status === "absent" + ? config.reason === null + : config.reason !== null) + ); +}; + +const payloadAgrees = (report: KernelBuildConfig): boolean => { + const { payload } = report; + if ((payload.status === "unsupported") !== (report.next_probe !== null)) + return false; + if (payload.inflated_bytes !== null && payload.status !== "inflated") + return false; + if (payload.container === "file") { + if (payload.status === "uncompressed") + return ( + payload.codec === null && + payload.offset === null && + payload.bytes === null + ); + return ( + payload.status === "unsupported" && + report.format === "file" && + payload.codec !== null && + payload.offset === 0 && + payload.bytes === report.artifact.bytes + ); + } + if (report.format !== "file") return false; + if (payload.status === "unavailable" || payload.status === "truncated") + if (payload.offset === null) + return payload.codec === null && payload.bytes === null; + if (payload.offset === null || payload.bytes === null) return false; + if (payload.status === "inflated") + return payload.codec === "gzip" && payload.inflated_bytes !== null; + if (payload.status === "truncated" || payload.status === "malformed") + return payload.codec === "gzip"; + return payload.status === "unsupported" && payload.codec !== "gzip"; +}; + +const searchedAgrees = (report: KernelBuildConfig): boolean => { + const searched = + report.payload.status === "uncompressed" || + report.payload.status === "inflated"; + if (searched) { + const examined = + report.payload.status === "inflated" + ? report.payload.inflated_bytes + : report.artifact.bytes; + if (report.coverage.examined_bytes !== examined) return false; + if (report.banner.status === "unknown") return false; + if (report.config.status === "present" || report.config.status === "absent") + return report.coverage.status === "complete"; + return ( + (report.config.status === "incomplete" || + report.config.status === "malformed") && + report.coverage.status === "incomplete" && + report.coverage.examined_entries === 0 + ); + } + if ( + report.coverage.examined_bytes !== 0 || + report.coverage.examined_entries !== 0 + ) + return false; + if (report.banner.status !== "unknown") return false; + if (report.payload.status === "unsupported") + return ( + report.coverage.status === "unsupported" && + report.config.status === "unsupported" + ); + return ( + report.coverage.status === "incomplete" && + report.config.status === "incomplete" + ); +}; + +const validReport = ( + report: KernelBuildConfig, + input: InspectKernelBuildConfigInput, +): boolean => { + if ( + report.artifact.bytes > input.max_file_bytes || + report.coverage.examined_entries > input.max_entries || + (report.payload.inflated_bytes ?? 0) > input.max_payload_bytes + ) + return false; + if ( + report.config.offset !== input.offset || + report.config.limit !== input.limit || + report.config.name !== (input.name ?? null) + ) + return false; + if ( + report.limitations.length === 0 || + !report.limitations.some((line) => line.includes("untrusted")) + ) + return false; + if (report.config.status === "present") { + if ( + report.coverage.examined_entries !== report.config.option_count || + !markersAgree(report.config) + ) + return false; + } else if (report.coverage.examined_entries !== 0) return false; + return ( + rangesAgree(report) && + bannerAgrees(report.banner) && + configCountsAgree(report.config) && + report.config.entries.every(valueAgrees) && + payloadAgrees(report) && + searchedAgrees(report) + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 448af4c8bd..a7aafeef62 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -13,6 +13,7 @@ import { createAppleIpsService, createDevicetreeService, createKernelModuleService, + createKernelBuildConfigService, createPeLoadConfigService, createBinaryHardeningService, createPeResourcesService, @@ -36,6 +37,7 @@ import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports. import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; import { inspectKernelModuleInputSchema } from "../domain/native/kernelModule.js"; +import { inspectKernelBuildConfigInputSchema } from "../domain/native/kernelBuildConfig.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; import { withEvidenceCallerInputPath } from "../domain/evidence.js"; @@ -195,6 +197,64 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectKernelBuildConfig, { + description: + "Inspect a Linux kernel image's version banner and embedded build config without executing it", + args: z.object({ + path: localPathStringSchema.describe( + "Local vmlinux ELF, raw Image, x86 bzImage/zImage, or configs.ko", + ), + }), + options: z.object({ + maxFileBytes: cliDecimalField( + inspectKernelBuildConfigInputSchema.shape.max_file_bytes, + ), + maxEntries: cliDecimalField( + inspectKernelBuildConfigInputSchema.shape.max_entries, + ), + maxConfigBytes: cliDecimalField( + inspectKernelBuildConfigInputSchema.shape.max_config_bytes, + ), + maxPayloadBytes: cliDecimalField( + inspectKernelBuildConfigInputSchema.shape.max_payload_bytes, + ), + offset: cliDecimalField(inspectKernelBuildConfigInputSchema.shape.offset), + limit: cliDecimalField(inspectKernelBuildConfigInputSchema.shape.limit), + name: inspectKernelBuildConfigInputSchema.shape.name, + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const kernelBuildConfig = createKernelBuildConfigService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectKernelBuildConfig, + async () => + finishCliEvidenceCommand( + args.path, + () => + kernelBuildConfig.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_entries: options.maxEntries, + max_config_bytes: options.maxConfigBytes, + max_payload_bytes: options.maxPayloadBytes, + offset: options.offset, + limit: options.limit, + ...(options.name === undefined + ? {} + : { name: options.name }), + }, + { signal }, + ), + kernelBuildConfig, + "rea-kernel-build-config", + "inspect_kernel_build_config", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); cli.command(CLI_COMMANDS.inspectPeLoadConfig, { description: "Inspect PE TLS callbacks and load configuration without executing the image", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index fb66e04616..bd0cd72b9f 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -85,6 +85,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectElfNotes: "inspect-elf-notes", inspectAppleIps: "inspect-apple-ips", inspectKernelModule: "inspect-kernel-module", + inspectKernelBuildConfig: "inspect-kernel-build-config", inspectPeLoadConfig: "inspect-pe-load-config", inspectBinaryHardening: "inspect-binary-hardening", inspectAnalysisView: "inspect-analysis-view", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index f8e15a718e..c54c377638 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -22,6 +22,8 @@ import { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsServic import { AppleIpsProvider } from "../native/apple/AppleIpsProvider.js"; import { KernelModuleService } from "../application/binaryDiagnostics/KernelModuleService.js"; import { KernelModuleProvider } from "../native/elf/KernelModuleProvider.js"; +import { KernelBuildConfigService } from "../application/binaryDiagnostics/KernelBuildConfigService.js"; +import { KernelBuildConfigProvider } from "../native/elf/KernelBuildConfigProvider.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; @@ -62,6 +64,9 @@ export const createAppleIpsService = (): AppleIpsService => /** Portable Linux kernel-module metadata inspection with no engine or host `modinfo`. */ export const createKernelModuleService = (): KernelModuleService => new KernelModuleService(new KernelModuleProvider()); +/** Portable Linux kernel banner and embedded build-config inspection with no engine or host tools. */ +export const createKernelBuildConfigService = (): KernelBuildConfigService => + new KernelBuildConfigService(new KernelBuildConfigProvider()); /** Compose the offline adapter without import-time I/O or dependency acquisition. */ export const createBinaryLayoutService = ( diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index b9a680b609..b80b101013 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -34,6 +34,7 @@ import { JAVASCRIPT_APPLICATION_PROVIDER, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, + KERNEL_BUILD_CONFIG_PROVIDER, KERNEL_MODULE_PROVIDER, MANAGED_STATIC_PROVIDER, MANAGED_WORKFLOW_PROVIDER, @@ -367,6 +368,13 @@ export const CAPABILITY_DECLARATIONS = { providerBinding(JEB_PROVIDER_IDENTITY, { group: "JEB_TOOL_CONTRACTS" }), ], }, + kernelBuildConfig: { + providers: [ + providerBinding(KERNEL_BUILD_CONFIG_PROVIDER, [ + "inspect_kernel_build_config", + ]), + ], + }, kernelModule: { providers: [ providerBinding(KERNEL_MODULE_PROVIDER, ["inspect_kernel_module"]), diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index fad6ecc4d0..a31514a86c 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -83,6 +83,7 @@ import { createElfNotesService, createAppleIpsService, createKernelModuleService, + createKernelBuildConfigService, } from "./binaryDiagnostics.js"; import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; @@ -362,6 +363,7 @@ const modules = { elfNotes: service(() => createElfNotesService()), appleIps: service(() => createAppleIpsService()), kernelModule: service(() => createKernelModuleService()), + kernelBuildConfig: service(() => createKernelBuildConfigService()), peLoadConfig: service(() => createPeLoadConfigService()), binaryHardening: service(() => createBinaryHardeningService()), peCertificates: service(() => createPeCertificatesService()), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 9648e23d03..f1272ef9d1 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -60,6 +60,10 @@ import { inspectKernelModuleInputSchema, kernelModuleSchema, } from "../../domain/native/kernelModule.js"; +import { + inspectKernelBuildConfigInputSchema, + kernelBuildConfigSchema, +} from "../../domain/native/kernelBuildConfig.js"; import { inspectWindowsShortcutInputSchema, windowsShortcutSchema, @@ -182,6 +186,28 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_kernel_build_config", + ...toolContractMetadata("inspect_kernel_build_config"), + kind: "native-provider", + description: + "Inspect one explicit local Linux kernel image (vmlinux ELF, raw arm64 Image, x86 bzImage/zImage, or configs.ko) without executing it or opening a disassembler. Returns the `Linux version` banner split into version, builder, and compiler, and the embedded CONFIG_IKCONFIG build config: option count, SHA-256 of the inflated text, and paged CONFIG_* entries with value kind (y, m, string, number, not_set), optionally filtered by exact name. A config is accepted only when IKCFG_ST is immediately followed by a gzip member that inflates to config text and is immediately followed by IKCFG_ED, so an ordinary IKCFG_ST string is not a config. A bzImage gzip payload located through the setup header is inflated with a bounded output budget and searched; xz, lz4, lzma, zstd, bzip2, and lzo payloads are named as unsupported with external_decompression as the next probe. Truncated payloads and gzip members are incomplete, not absent. Reads a stable regular file; exhausted budgets return no partial success. Portable on supported Node hosts.", + inputSchema: inspectKernelBuildConfigInputSchema, + outputSchema: evidenceResultOf(kernelBuildConfigSchema), + examples: [ + { + title: "Read a kernel image's version banner and embedded build config", + input: { path: "/artifacts/vmlinux" }, + }, + { + title: "Check one security option in an embedded kernel config", + input: { + path: "/artifacts/bzImage", + name: "CONFIG_SECURITY_SELINUX", + }, + }, + ], + }, { name: "inspect_byte_entropy", ...toolContractMetadata("inspect_byte_entropy"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index a94531d0f8..dfd51bc2be 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -296,6 +296,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_apple_ips: evidence, inspect_devicetree: evidence, inspect_kernel_module: evidence, + inspect_kernel_build_config: evidence, inspect_v8_code_cache: evidence, inspect_pe_load_config: evidence, inspect_binary_hardening: evidence, diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index a53d7a9536..f80e47affb 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -37,6 +37,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_api_contract" | "inspect_playwright_trace" | "inspect_kernel_module" + | "inspect_kernel_build_config" | "inspect_erlang_beam" | "inspect_v8_code_cache" | "extract_artifact" diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index fdab763a46..d71013fc4e 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -73,6 +73,13 @@ export const KERNEL_MODULE_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free Linux kernel banner and IKCONFIG decoding. */ +export const KERNEL_BUILD_CONFIG_PROVIDER = Object.freeze({ + id: "rea-kernel-build-config", + name: "REA kernel build config parser", + version: "1", +} as const); + /** Provider identity for execution-free Shannon byte-entropy profiling. */ export const BYTE_ENTROPY_PROVIDER = Object.freeze({ id: "byte-entropy", diff --git a/src/domain/native/kernelBuildConfig.test.ts b/src/domain/native/kernelBuildConfig.test.ts new file mode 100644 index 0000000000..89a90f186e --- /dev/null +++ b/src/domain/native/kernelBuildConfig.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "vitest"; +import { inspectKernelBuildConfigInputSchema } from "./kernelBuildConfig.js"; + +it("rejects extra caller arguments and applies kernel build config budgets", () => { + expect( + inspectKernelBuildConfigInputSchema.safeParse({ + path: "/artifacts/vmlinux", + extra: true, + }).success, + ).toBe(false); + expect( + inspectKernelBuildConfigInputSchema.safeParse({ + path: "/artifacts/vmlinux", + name: "SECURITY_SELINUX", + }).success, + ).toBe(false); + expect( + inspectKernelBuildConfigInputSchema.parse({ path: "/artifacts/vmlinux" }), + ).toMatchObject({ + max_file_bytes: 67108864, + max_entries: 16384, + max_config_bytes: 4194304, + max_payload_bytes: 134217728, + offset: 0, + limit: 256, + }); +}); diff --git a/src/domain/native/kernelBuildConfig.ts b/src/domain/native/kernelBuildConfig.ts new file mode 100644 index 0000000000..bc878365f1 --- /dev/null +++ b/src/domain/native/kernelBuildConfig.ts @@ -0,0 +1,229 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_KERNEL_BUILD_CONFIG_FILE_BYTES = 536870912; +/** Hard ceiling on CONFIG_* options examined from one embedded config. */ +export const MAX_INSPECT_KERNEL_BUILD_CONFIG_ENTRIES = 65536; +/** Hard ceiling on the inflated IKCFG gzip member. */ +export const MAX_INSPECT_KERNEL_BUILD_CONFIG_CONFIG_BYTES = 33554432; +/** Hard ceiling on an inflated bzImage payload searched for the banner and config. */ +export const MAX_INSPECT_KERNEL_BUILD_CONFIG_PAYLOAD_BYTES = 536870912; +/** Hard ceiling on one page of CONFIG_* entries. */ +export const MAX_INSPECT_KERNEL_BUILD_CONFIG_PAGE = 4096; + +export const KERNEL_BUILD_CONFIG_CODECS = [ + "gzip", + "xz", + "lz4", + "lzma", + "zstd", + "bzip2", + "lzo", +] as const; + +export const KERNEL_BUILD_CONFIG_VALUE_KINDS = [ + "y", + "m", + "string", + "number", + "not_set", +] as const; + +/** Reasons a framed `IKCFG_ST` ... `IKCFG_ED` blob is reported as malformed. */ +export const KERNEL_BUILD_CONFIG_MALFORMED_REASONS = [ + "config_gzip_invalid", + "config_text_invalid", +] as const; + +export const KERNEL_BUILD_CONFIG_REASONS = [ + ...KERNEL_BUILD_CONFIG_MALFORMED_REASONS, + "config_gzip_truncated", + "config_search_exhausted", + "payload_truncated", + "payload_gzip_invalid", + "payload_codec_unsupported", + "unrecognized_payload_codec", + "setup_header_payload_unavailable", +] as const; + +const range = z.strictObject({ + /** `file` is a range in the selected image. `inflated_payload` is a range in the inflated bzImage payload. */ + space: z.enum(["file", "inflated_payload"]), + offset: uint, + bytes: uint, +}); + +/** Matches a Kconfig `int` or `hex` value as written to `.config`. */ +export const KERNEL_BUILD_CONFIG_NUMBER = + /^(?:-?(?:0|[1-9][0-9]*)|0[xX][0-9A-Fa-f]+)$/u; +/** Length of the `IKCFG_ST` and `IKCFG_ED` markers. */ +export const KERNEL_BUILD_CONFIG_MARKER_BYTES = 8; + +const entrySchema = z.strictObject({ + name: z.string().regex(/^CONFIG_[A-Za-z0-9_]+$/u), + kind: z.enum(KERNEL_BUILD_CONFIG_VALUE_KINDS), + /** Null only for `not_set`. Strings are the decoded quoted text. */ + value: z.string().nullable(), + trust: z.literal("target-derived-untrusted"), +}); + +const bannerSchema = z.strictObject({ + status: z.enum(["present", "absent", "unknown"]), + text: z.string().nullable(), + version: z.string().nullable(), + builder: z.string().nullable(), + compiler: z.string().nullable(), + location: range.nullable(), + trust: z.literal("target-derived-untrusted"), +}); + +const payloadSchema = z.strictObject({ + container: z.enum(["file", "bzimage"]), + codec: z.enum(KERNEL_BUILD_CONFIG_CODECS).nullable(), + status: z.enum([ + "uncompressed", + "inflated", + "unsupported", + "truncated", + "malformed", + "unavailable", + ]), + /** File offset of a bzImage payload, or 0 for a whole-file codec. Null when the file itself was searched. */ + offset: uint.nullable(), + bytes: uint.nullable(), + inflated_bytes: uint.nullable(), +}); + +/** One bounded read of a kernel image's version banner and embedded `.config`. */ +export const inspectKernelBuildConfigInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a vmlinux ELF, raw arm64 Image, x86 bzImage/zImage, or configs.ko module", + ), + max_file_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_FILE_BYTES) + .default(67108864) + .describe("Maximum stable artifact bytes to read (default 64 MiB)"), + max_entries: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_ENTRIES) + .default(16384) + .describe( + "Maximum CONFIG_* options to examine; exceeding the budget fails without partial success", + ), + max_config_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_CONFIG_BYTES) + .default(4194304) + .describe( + "Maximum inflated bytes of one IKCFG gzip member (default 4 MiB)", + ), + max_payload_bytes: z + .number() + .int() + .min(64) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_PAYLOAD_BYTES) + .default(134217728) + .describe( + "Maximum inflated bytes of a bzImage gzip payload (default 128 MiB)", + ), + offset: z + .number() + .int() + .min(0) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_ENTRIES) + .default(0) + .describe("Number of matching CONFIG_* options to skip"), + limit: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_KERNEL_BUILD_CONFIG_PAGE) + .default(256) + .describe("Maximum CONFIG_* options to return in this page"), + name: z + .string() + .regex(/^CONFIG_[A-Za-z0-9_]+$/u) + .optional() + .describe( + "Exact CONFIG_* name to return. Omitted returns every option in the page", + ), +}); + +/** + * Version banner and embedded build config. A config is present only when + * `IKCFG_ST` is immediately followed by a gzip member whose inflated text is + * a config and whose next bytes are `IKCFG_ED`. A framed blob whose member does + * not decode, fails its trailer check, or whose text fails the grammar is + * `malformed`. + * Malformed, truncated, or unsupported input is never an absent config. + */ +export const kernelBuildConfigSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + format: z.enum(["elf", "file"]), + payload: payloadSchema, + next_probe: z + .strictObject({ + operation: z + .literal("external_decompression") + .describe( + "External decompression with a codec utility, not a REA tool", + ), + rationale: z.string().min(1), + }) + .nullable(), + banner: bannerSchema, + config: z.strictObject({ + status: z.enum([ + "present", + "absent", + "malformed", + "incomplete", + "unsupported", + ]), + reason: z.enum(KERNEL_BUILD_CONFIG_REASONS).nullable(), + marker: range.nullable(), + end_marker: range.nullable(), + gzip: range.nullable(), + sha256: digestSchema.nullable(), + /** CONFIG_* options in the accepted config, before the name filter and page. */ + option_count: uint, + /** Options selected by the name filter, before the page. */ + matched_count: uint, + offset: uint, + limit: uint, + name: z + .string() + .regex(/^CONFIG_[A-Za-z0-9_]+$/u) + .nullable(), + entries: z.array(entrySchema), + }), + coverage: z.strictObject({ + status: z.enum(["complete", "incomplete", "unsupported"]), + examined_bytes: uint, + examined_entries: uint, + }), + limitations: z.array(z.string()), +}); + +export type KernelBuildConfig = z.infer; +export type InspectKernelBuildConfigInput = z.infer< + typeof inspectKernelBuildConfigInputSchema +>; +export type KernelBuildConfigEntry = + KernelBuildConfig["config"]["entries"][number]; diff --git a/src/native/elf/KernelBuildConfig.fixture.ts b/src/native/elf/KernelBuildConfig.fixture.ts new file mode 100644 index 0000000000..63c16d28ca --- /dev/null +++ b/src/native/elf/KernelBuildConfig.fixture.ts @@ -0,0 +1,204 @@ +import { crc32, deflateRawSync, gzipSync } from "node:zlib"; + +const concat = (...parts: readonly Buffer[]): Buffer => Buffer.concat(parts); + +/** Banner with a nested compiler parenthesis, terminated by the caller. */ +export const SAMPLE_KERNEL_BANNER = + "Linux version 6.6.0 (build@host) (gcc (Debian 12.2.0-14) 12.2.0, GNU ld (GNU Binutils for Debian) 2.40) #1 SMP"; + +/** Config text accepted by the strict IKCFG grammar. */ +export const SAMPLE_KERNEL_CONFIG = [ + "#", + "# Linux/arm64 6.6.0 Kernel Configuration", + "#", + "CONFIG_SECURITY_SELINUX=y", + "CONFIG_STACKPROTECTOR=y", + "CONFIG_MODULE_SIG=m", + 'CONFIG_LOCALVERSION=""', + 'CONFIG_DEFAULT_HOSTNAME="(none)"', + 'CONFIG_CMDLINE="console=ttyS0 \\"debug\\""', + "CONFIG_HZ=250", + "CONFIG_PAGE_OFFSET=0xC0000000", + "# CONFIG_MODULE_SIG_FORCE is not set", + "", +].join("\n"); + +/** `IKCFG_ST` + gzip member + `IKCFG_ED` for the sample config. */ +export const embeddedKernelConfig = ( + text: string = SAMPLE_KERNEL_CONFIG, +): Buffer => + concat( + Buffer.from("IKCFG_ST"), + gzipSync(Buffer.from(text, "latin1")), + Buffer.from("IKCFG_ED"), + ); + +const elfPrefix = (): Buffer => { + const header = Buffer.alloc(64); + header[0] = 0x7f; + header[1] = 0x45; + header[2] = 0x4c; + header[3] = 0x46; + header[4] = 2; + header[5] = 1; + return header; +}; + +/** + * Synthetic ELF whose file bytes contain a decoy `IKCFG_ST` string, the sample + * banner, and a real gzip config. The decoy is the #5715 false-positive shape. + */ +export const kernelBuildConfigFixture = (): Buffer => + concat( + elfPrefix(), + Buffer.from(`${SAMPLE_KERNEL_BANNER}\0`, "latin1"), + Buffer.from("IKCFG_ST not a gzip member\0", "latin1"), + embeddedKernelConfig(), + ); + +/** ELF with a banner and an `IKCFG_ST` string that is not a gzip member. */ +export const plainIkcfgStringFixture = (): Buffer => + concat( + elfPrefix(), + Buffer.from(`${SAMPLE_KERNEL_BANNER}\0`, "latin1"), + Buffer.from("ordinary IKCFG_ST string without gzip\0", "latin1"), + ); + +/** ELF whose `IKCFG_ST` is followed by a gzip header that ends early. */ +export const truncatedKernelConfigFixture = (): Buffer => { + const member = gzipSync(Buffer.from(SAMPLE_KERNEL_CONFIG, "latin1")); + return concat( + elfPrefix(), + Buffer.from(`${SAMPLE_KERNEL_BANNER}\0`, "latin1"), + Buffer.from("IKCFG_ST"), + member.subarray(0, 12), + ); +}; + +/** ELF whose IKCFG member has trailing bytes before IKCFG_ED. */ +export const trailingBytesKernelConfigFixture = (): Buffer => + concat( + elfPrefix(), + Buffer.from("IKCFG_ST"), + gzipSync(Buffer.from(SAMPLE_KERNEL_CONFIG, "latin1")), + Buffer.from("junk"), + Buffer.from("IKCFG_ED"), + ); + +/** ELF whose IKCFG member inflates to text that is not a `.config`. */ +export const nonConfigGzipFixture = (): Buffer => + concat( + elfPrefix(), + Buffer.from("IKCFG_ST"), + gzipSync(Buffer.from("hello world\n", "latin1")), + Buffer.from("IKCFG_ED"), + ); + +/** ELF whose framed IKCFG member inflates but carries a wrong CRC-32. */ +export const badCrcKernelConfigFixture = (): Buffer => { + const member = gzipSync(Buffer.from(SAMPLE_KERNEL_CONFIG, "latin1")); + const crc = member.length - 8; + member.writeUInt32LE((member.readUInt32LE(crc) ^ 0xffffffff) >>> 0, crc); + return concat( + elfPrefix(), + Buffer.from("IKCFG_ST"), + member, + Buffer.from("IKCFG_ED"), + ); +}; + +export interface BzImageFixtureOptions { + readonly version?: number; + readonly payloadOffset?: number; + readonly decoyBanner?: string; +} + +/** x86 setup header (`HdrS`) with `payload_offset` / `payload_length`. */ +export const bzImageFixture = ( + payload: Buffer, + options: BzImageFixtureOptions = {}, +): Buffer => { + const setupSects = 4; + const setupSize = (setupSects + 1) * 512; + const payloadOffset = options.payloadOffset ?? 16; + const fileOffset = setupSize + payloadOffset; + const image = Buffer.alloc(fileOffset + payload.length); + image[0x1f1] = setupSects; + image.write("HdrS", 0x202, "latin1"); + image.writeUInt16LE(options.version ?? 0x0208, 0x206); + image.writeUInt32LE(payloadOffset, 0x248); + image.writeUInt32LE(payload.length, 0x24c); + if (options.decoyBanner !== undefined) + image.write(options.decoyBanner, 0x300, "latin1"); + payload.copy(image, fileOffset); + return image; +}; + +const searchedPayload = (): Buffer => + concat( + Buffer.from(`${SAMPLE_KERNEL_BANNER}\0`, "latin1"), + embeddedKernelConfig(), + ); + +/** + * A gzip member followed by unrelated bytes inside the payload. Kbuild relies + * on gzip's own ISIZE trailer and appends no size field for gzip, but bytes + * after the member must still be tolerated. + */ +const withTrailingBytes = (plain: Buffer): Buffer => + concat(gzipSync(plain), Buffer.from([0xde, 0xad, 0xbe, 0xef])); + +/** bzImage whose gzip payload carries the sample banner and config. */ +export const bzImageGzipFixture = (): Buffer => + bzImageFixture(withTrailingBytes(searchedPayload()), { + decoyBanner: "Linux version 1.0.0 (decoy@setup) (gcc) #0\n", + }); + +/** bzImage whose gzip payload is cut short inside the deflate stream. */ +export const bzImageTruncatedGzipFixture = (): Buffer => + bzImageFixture(gzipSync(searchedPayload()).subarray(0, 24)); + +/** bzImage whose payload starts with the xz magic and is not inflated. */ +export const bzImageXzFixture = (): Buffer => + bzImageFixture( + concat(Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]), Buffer.alloc(8)), + ); + +/** + * gzip member with FHCRC and FNAME set, built by hand since `gzipSync` writes + * neither. `headerCrc` overrides the header checksum to model damage. + */ +export const headerCrcGzipMember = ( + plain: Buffer, + headerCrc?: number, +): Buffer => { + const header = concat( + Buffer.from([0x1f, 0x8b, 0x08, 0x0a, 0, 0, 0, 0, 0, 0x03]), + Buffer.from("config\0", "latin1"), + ); + const checksum = Buffer.alloc(2); + checksum.writeUInt16LE(headerCrc ?? crc32(header) & 0xffff); + const trailer = Buffer.alloc(8); + trailer.writeUInt32LE(crc32(plain), 0); + trailer.writeUInt32LE(plain.length >>> 0, 4); + return concat(header, checksum, deflateRawSync(plain), trailer); +}; + +/** A `CONFIG_EXTRA_FIRMWARE` list longer than any fixed string cap. */ +export const LONG_KERNEL_CONFIG_STRING = Array.from( + { length: 400 }, + (_, index) => `vendor/fw-${String(index).padStart(3, "0")}.bin`, +).join(" "); + +/** ELF whose embedded config carries one long, escaped string value. */ +export const longStringKernelConfigFixture = (): Buffer => + concat( + elfPrefix(), + embeddedKernelConfig( + [ + "CONFIG_SECURITY_SELINUX=y", + `CONFIG_EXTRA_FIRMWARE="${LONG_KERNEL_CONFIG_STRING} \\"q\\" \\\\"`, + "", + ].join("\n"), + ), + ); diff --git a/src/native/elf/KernelBuildConfig.test.ts b/src/native/elf/KernelBuildConfig.test.ts new file mode 100644 index 0000000000..eb9f93c6ea --- /dev/null +++ b/src/native/elf/KernelBuildConfig.test.ts @@ -0,0 +1,721 @@ +import { createHash } from "node:crypto"; +import { setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { gzipSync } from "node:zlib"; +import { expect, it } from "vitest"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + inspectKernelBuildConfigInputSchema, + kernelBuildConfigSchema, + type InspectKernelBuildConfigInput, +} from "../../domain/native/kernelBuildConfig.js"; +import { + badCrcKernelConfigFixture, + bzImageFixture, + bzImageGzipFixture, + bzImageTruncatedGzipFixture, + bzImageXzFixture, + embeddedKernelConfig, + headerCrcGzipMember, + kernelBuildConfigFixture, + LONG_KERNEL_CONFIG_STRING, + longStringKernelConfigFixture, + nonConfigGzipFixture, + plainIkcfgStringFixture, + SAMPLE_KERNEL_BANNER, + SAMPLE_KERNEL_CONFIG, + trailingBytesKernelConfigFixture, + truncatedKernelConfigFixture, +} from "./KernelBuildConfig.fixture.js"; +import { + KernelBuildConfigBudgetFailure, + parseKernelBuildConfig, +} from "./KernelBuildConfig.js"; + +const artifactPath = "/artifacts/vmlinux"; + +const parse = async ( + bytes: Buffer, + extra: Partial> = {}, +) => + kernelBuildConfigSchema.parse( + await parseKernelBuildConfig( + bytes, + { path: artifactPath, sha256: "ab".repeat(32), bytes: bytes.length }, + inspectKernelBuildConfigInputSchema.parse({ + path: artifactPath, + ...extra, + }), + ), + ); + +it("reads the banner and the gzip IKCFG config after skipping a decoy marker", async () => { + const bytes = kernelBuildConfigFixture(); + const report = await parse(bytes); + expect(report.format).toBe("elf"); + expect(report.payload.status).toBe("uncompressed"); + expect(report.banner).toMatchObject({ + status: "present", + text: SAMPLE_KERNEL_BANNER, + version: "6.6.0", + builder: "build@host", + compiler: + "gcc (Debian 12.2.0-14) 12.2.0, GNU ld (GNU Binutils for Debian) 2.40", + location: { space: "file", offset: 64 }, + }); + const embedded = embeddedKernelConfig(); + const marker = bytes.indexOf(embedded); + expect(report.config).toMatchObject({ + status: "present", + reason: null, + option_count: 9, + matched_count: 9, + marker: { space: "file", offset: marker, bytes: 8 }, + gzip: { space: "file", offset: marker + 8, bytes: embedded.length - 16 }, + end_marker: { offset: marker + embedded.length - 8, bytes: 8 }, + sha256: createHash("sha256").update(SAMPLE_KERNEL_CONFIG).digest("hex"), + }); + expect( + report.config.entries.map((entry) => [entry.name, entry.kind, entry.value]), + ).toEqual([ + ["CONFIG_SECURITY_SELINUX", "y", "y"], + ["CONFIG_STACKPROTECTOR", "y", "y"], + ["CONFIG_MODULE_SIG", "m", "m"], + ["CONFIG_LOCALVERSION", "string", ""], + ["CONFIG_DEFAULT_HOSTNAME", "string", "(none)"], + ["CONFIG_CMDLINE", "string", 'console=ttyS0 "debug"'], + ["CONFIG_HZ", "number", "250"], + ["CONFIG_PAGE_OFFSET", "number", "0xC0000000"], + ["CONFIG_MODULE_SIG_FORCE", "not_set", null], + ]); + expect(report.coverage).toEqual({ + status: "complete", + examined_bytes: bytes.length, + examined_entries: 9, + }); +}); + +it("filters by exact name and pages without changing the option count", async () => { + const named = await parse(kernelBuildConfigFixture(), { + name: "CONFIG_MODULE_SIG_FORCE", + }); + expect(named.config).toMatchObject({ + option_count: 9, + matched_count: 1, + name: "CONFIG_MODULE_SIG_FORCE", + entries: [{ name: "CONFIG_MODULE_SIG_FORCE", kind: "not_set" }], + }); + const paged = await parse(kernelBuildConfigFixture(), { + offset: 7, + limit: 5, + }); + expect(paged.config.entries.map((entry) => entry.name)).toEqual([ + "CONFIG_PAGE_OFFSET", + "CONFIG_MODULE_SIG_FORCE", + ]); + expect( + (await parse(kernelBuildConfigFixture(), { offset: 50 })).config.entries, + ).toEqual([]); +}); + +it("accepts a string value longer than 4096 characters", async () => { + expect(LONG_KERNEL_CONFIG_STRING.length).toBeGreaterThan(4096); + const report = await parse(longStringKernelConfigFixture()); + expect(report.config).toMatchObject({ + status: "present", + option_count: 2, + }); + expect(report.config.entries[1]).toMatchObject({ + name: "CONFIG_EXTRA_FIRMWARE", + kind: "string", + value: `${LONG_KERNEL_CONFIG_STRING} "q" \\`, + }); + expect( + ( + await parse(longStringKernelConfigFixture(), { + name: "CONFIG_SECURITY_SELINUX", + }) + ).config, + ).toMatchObject({ status: "present", matched_count: 1 }); +}); + +it.each(["disabled", "assigned"])( + "retains a long %s config name", + async (kind) => { + const name = `CONFIG_${"A".repeat(260)}`; + const option = kind === "disabled" ? `# ${name} is not set` : `${name}=y`; + const bytes = framed(gzipSync(Buffer.from(`${option}\nCONFIG_OK=y\n`))); + const report = await parse(bytes, { name }); + expect(report.config).toMatchObject({ + status: "present", + option_count: 2, + matched_count: 1, + entries: [{ name, kind: kind === "disabled" ? "not_set" : "y" }], + }); + }, +); + +it("keeps the strict grammar for malformed quoted values", async () => { + for (const value of [ + '"open', + '"bad \\q"', + '"a"b"', + '"tail\\"', + '"a\rb"', + '"a\u2028b"', + "y\u2028", + ]) + expect( + ( + await parse( + framed(gzipSync(Buffer.from(`CONFIG_X=${value}\n`, "utf8"))), + ) + ).config.status, + ).toBe("malformed"); +}); + +it("does not treat an ordinary IKCFG_ST string as a config", async () => { + const report = await parse(plainIkcfgStringFixture()); + expect(report.config).toMatchObject({ + status: "absent", + reason: null, + marker: null, + }); + expect(report.banner.status).toBe("present"); + expect(report.coverage.status).toBe("complete"); +}); + +it("skips a member that is not immediately followed by IKCFG_ED", async () => { + expect((await parse(trailingBytesKernelConfigFixture())).config.status).toBe( + "absent", + ); +}); + +it("reports a framed member whose text fails the grammar as malformed, not absent", async () => { + const bytes = nonConfigGzipFixture(); + const report = await parse(bytes); + expect(report.config).toMatchObject({ + status: "malformed", + reason: "config_text_invalid", + marker: { space: "file", offset: 64, bytes: 8 }, + gzip: { space: "file", offset: 72, bytes: bytes.length - 64 - 16 }, + end_marker: { space: "file", offset: bytes.length - 8, bytes: 8 }, + sha256: null, + option_count: 0, + entries: [], + }); + expect(report.coverage.status).toBe("incomplete"); + expect( + report.limitations.some((line) => line.includes("not a .config")), + ).toBe(true); +}); + +it("reports a framed member with a bad CRC-32 as malformed, not absent", async () => { + const report = await parse(badCrcKernelConfigFixture()); + expect(report.config).toMatchObject({ + status: "malformed", + reason: "config_gzip_invalid", + marker: { offset: 64 }, + sha256: null, + option_count: 0, + }); + expect(report.coverage.status).toBe("incomplete"); + expect(report.limitations.some((line) => line.includes("CRC-32"))).toBe(true); +}); + +it("checks the optional gzip header CRC before accepting a member", async () => { + const plain = Buffer.from(SAMPLE_KERNEL_CONFIG, "latin1"); + const frame = (member: Buffer): Buffer => + Buffer.concat([Buffer.from("IKCFG_ST"), member, Buffer.from("IKCFG_ED")]); + expect((await parse(frame(headerCrcGzipMember(plain)))).config).toMatchObject( + { + status: "present", + option_count: 9, + }, + ); + const good = headerCrcGzipMember(plain); + const damaged = headerCrcGzipMember( + plain, + (good.readUInt16LE(17) ^ 0xffff) & 0xffff, + ); + expect((await parse(frame(damaged))).config.status).not.toBe("present"); + const payload = await parse( + bzImageFixture( + headerCrcGzipMember( + Buffer.concat([ + Buffer.from(`${SAMPLE_KERNEL_BANNER}\0`, "latin1"), + embeddedKernelConfig(), + ]), + 0, + ), + ), + ); + expect(payload.payload.status).toBe("malformed"); + expect(payload.config.reason).toBe("payload_gzip_invalid"); +}); + +it("reports a framed member that does not decode as malformed, not absent", async () => { + const member = gzipSync(Buffer.from(SAMPLE_KERNEL_CONFIG, "latin1")); + const damaged = Buffer.from(member); + // The first DEFLATE byte becomes a reserved block type. + damaged[10] = 0x07; + const cutOff = member.subarray(0, member.length - 20); + for (const body of [damaged, cutOff]) { + const bytes = Buffer.concat([ + Buffer.from("IKCFG_ST"), + body, + Buffer.from("IKCFG_ED"), + ]); + const report = await parse(bytes); + expect(report.config).toMatchObject({ + status: "malformed", + reason: "config_gzip_invalid", + marker: { space: "file", offset: 0, bytes: 8 }, + gzip: { space: "file", offset: 8, bytes: body.length }, + end_marker: { space: "file", offset: bytes.length - 8, bytes: 8 }, + }); + expect(report.coverage.status).toBe("incomplete"); + expect( + (await parse(Buffer.concat([bytes, embeddedKernelConfig()]))).config + .status, + ).toBe("present"); + } +}); + +it("prefers a valid config over an earlier malformed blob", async () => { + const report = await parse( + Buffer.concat([badCrcKernelConfigFixture(), embeddedKernelConfig()]), + ); + expect(report.config).toMatchObject({ status: "present", option_count: 9 }); + expect(report.coverage.status).toBe("complete"); +}); + +it("reports a truncated IKCFG member as incomplete, not absent", async () => { + const report = await parse(truncatedKernelConfigFixture()); + expect(report.config).toMatchObject({ + status: "incomplete", + reason: "config_gzip_truncated", + }); + expect(report.coverage.status).toBe("incomplete"); +}); + +it("inflates a bzImage gzip payload and reports payload-relative offsets", async () => { + const report = await parse(bzImageGzipFixture()); + expect(report.format).toBe("file"); + expect(report.payload).toMatchObject({ + container: "bzimage", + codec: "gzip", + status: "inflated", + offset: 5 * 512 + 16, + }); + expect(report.banner).toMatchObject({ + status: "present", + version: "6.6.0", + location: { space: "inflated_payload", offset: 0 }, + }); + expect(report.config).toMatchObject({ + status: "present", + option_count: 9, + marker: { space: "inflated_payload" }, + }); + expect(report.coverage.examined_bytes).toBe(report.payload.inflated_bytes); +}); + +it("names an xz bzImage payload as unsupported without searching it", async () => { + const report = await parse(bzImageXzFixture()); + expect(report).toMatchObject({ + payload: { container: "bzimage", codec: "xz", status: "unsupported" }, + next_probe: { operation: "external_decompression" }, + banner: { status: "unknown" }, + config: { status: "unsupported", reason: "payload_codec_unsupported" }, + coverage: { status: "unsupported", examined_bytes: 0 }, + }); +}); + +it("keeps truncated and unusable bzImage payloads incomplete", async () => { + const truncated = await parse(bzImageTruncatedGzipFixture()); + expect(truncated.payload.status).toBe("truncated"); + expect(truncated.config.reason).toBe("payload_truncated"); + expect(truncated.coverage.status).toBe("incomplete"); + + const old = await parse(bzImageFixture(gzipSync("x"), { version: 0x0206 })); + expect(old.payload.status).toBe("unavailable"); + expect(old.config.reason).toBe("setup_header_payload_unavailable"); + + const short = bzImageFixture(gzipSync("x")); + short.writeUInt32LE(0xffff, 0x24c); + expect((await parse(short)).config.reason).toBe("payload_truncated"); + + const garbage = bzImageFixture(Buffer.from("not compressed")); + expect(await parse(garbage)).toMatchObject({ + payload: { codec: null, status: "unsupported" }, + config: { reason: "unrecognized_payload_codec" }, + }); +}); + +it("detects the bzImage payload codec within the declared payload only", async () => { + const image = bzImageXzFixture(); + image.writeUInt32LE(1, 0x24c); + expect(await parse(image)).toMatchObject({ + payload: { codec: null, status: "unsupported", bytes: 1 }, + config: { reason: "unrecognized_payload_codec" }, + }); +}); + +it("names a whole-file codec stream without inflating it", async () => { + const report = await parse(gzipSync(Buffer.from(SAMPLE_KERNEL_CONFIG))); + expect(report).toMatchObject({ + payload: { container: "file", codec: "gzip", status: "unsupported" }, + config: { status: "unsupported" }, + next_probe: { operation: "external_decompression" }, + }); +}); + +it("fails closed when a config exceeds its entry or inflation budget", async () => { + // Recovery reads the typed knob, never the message text. + for (const [bytes, extra, knob] of [ + [kernelBuildConfigFixture(), { max_entries: 2 }, "max_entries"], + [kernelBuildConfigFixture(), { max_config_bytes: 64 }, "max_config_bytes"], + [bzImageGzipFixture(), { max_payload_bytes: 64 }, "max_payload_bytes"], + ] as const) { + const failure = parse(bytes, extra); + await expect(failure).rejects.toBeInstanceOf( + KernelBuildConfigBudgetFailure, + ); + await expect(failure).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + knob, + }); + } +}); + +it("ignores banner-shaped text that is unterminated or lacks a version", async () => { + const bytes = Buffer.concat([ + Buffer.from("Linux version of the docs\0"), + Buffer.from("Linux version 5.10.0"), + ]); + expect((await parse(bytes)).banner.status).toBe("absent"); +}); + +const framed = (member: Buffer): Buffer => + Buffer.concat([Buffer.from("IKCFG_ST"), member, Buffer.from("IKCFG_ED")]); + +it("stops inflating decoy candidates after the candidate cap and reports incomplete", async () => { + const decoy = framed(gzipSync(Buffer.from("hello world\n"))); + const report = await parse( + Buffer.concat([ + ...Array.from({ length: 16 }, () => decoy), + embeddedKernelConfig(), + ]), + ); + expect(report.config).toMatchObject({ + status: "incomplete", + reason: "config_search_exhausted", + marker: null, + }); + expect(report.coverage.status).toBe("incomplete"); + expect( + report.limitations.some((line) => line.includes("16 IKCFG_ST gzip")), + ).toBe(true); +}); + +it("caps total inflated bytes across candidates below each member's own budget", async () => { + const decoy = framed(gzipSync(Buffer.alloc(4000, 0x61))); + const report = await parse( + Buffer.concat([ + ...Array.from({ length: 5 }, () => decoy), + embeddedKernelConfig(), + ]), + { max_config_bytes: 4096 }, + ); + expect(report.config).toMatchObject({ + status: "incomplete", + reason: "config_search_exhausted", + }); + // Under the cap, the same decoys still yield the later config. + expect( + ( + await parse(Buffer.concat([decoy, decoy, embeddedKernelConfig()]), { + max_config_bytes: 4096, + }) + ).config.status, + ).toBe("present"); +}); + +it("charges cut-off decoy members against the total inflate budget", async () => { + // Incompressible bytes, so the cut-off stream inflates under the limit. + const noise = Buffer.concat( + Array.from({ length: 1875 }, (_, index) => + createHash("sha256").update(String(index)).digest(), + ), + ); + // Each decoy's deflate stream is cut off by its own IKCFG_ED. + const decoy = framed(gzipSync(noise).subarray(0, 30_000)); + const report = await parse( + Buffer.concat([ + ...Array.from({ length: 4 }, () => decoy), + embeddedKernelConfig(), + ]), + { max_config_bytes: 65536 }, + ); + expect(report.config).toMatchObject({ + status: "incomplete", + reason: "config_search_exhausted", + }); +}); + +it("bounds each candidate's inflate input by its closing IKCFG_ED", async () => { + const bomb = gzipSync(Buffer.alloc(1 << 20)); + const report = await parse( + Buffer.concat([ + Buffer.from("IKCFG_ST"), + bomb.subarray(0, 100), + Buffer.from("IKCFG_ED"), + bomb.subarray(100), + embeddedKernelConfig(), + ]), + { max_config_bytes: 65536 }, + ); + expect(report.config).toMatchObject({ status: "present", option_count: 9 }); +}); + +it("does not let an oversized decoy hide a later config", async () => { + const decoy = Buffer.concat([ + Buffer.from("IKCFG_ST"), + gzipSync(Buffer.alloc(200_000)), + Buffer.from("IKCFG_ED"), + ]); + const report = await parse(Buffer.concat([decoy, embeddedKernelConfig()]), { + max_config_bytes: 65536, + }); + expect(report.config).toMatchObject({ status: "present", option_count: 9 }); +}); + +it("finds a banner after overlapping unterminated repeats in one linear scan", async () => { + const repeats = Buffer.from("Linux version ".repeat(200_000)); + const rejected = await parse(Buffer.concat([repeats, Buffer.from([0x01])])); + expect(rejected.banner.status).toBe("absent"); + const bytes = Buffer.concat([ + repeats, + Buffer.from([0x01]), + Buffer.from("Linux version x ".repeat(100)), + Buffer.from("Linux version 6.1.0 (a@b) (gcc) #1\n"), + ]); + const report = await parse(bytes); + expect(report.banner).toMatchObject({ + status: "present", + text: "Linux version 6.1.0 (a@b) (gcc) #1", + version: "6.1.0", + location: { offset: bytes.length - 35, bytes: 34 }, + }); +}); + +it("honors cancellation during the marker, inflate, and banner scans", async () => { + const input = inspectKernelBuildConfigInputSchema.parse({ + path: artifactPath, + }); + const artifact = { path: artifactPath, sha256: "ab".repeat(32), bytes: 0 }; + const abortAfter = (checks: number): AbortSignal => { + let reads = 0; + return { + get aborted() { + reads += 1; + return reads > checks; + }, + reason: new Error("stop"), + } as unknown as AbortSignal; + }; + const decoy = framed(gzipSync(Buffer.from("hello world\n"))); + const cancelled = async (bytes: Buffer, checks: number): Promise => { + try { + await parseKernelBuildConfig(bytes, artifact, input, abortAfter(checks)); + } catch (error) { + return error; + } + return null; + }; + for (const [bytes, checks] of [ + [Buffer.concat([decoy, decoy, decoy]), 2], + [kernelBuildConfigFixture(), 0], + [Buffer.from("Linux version ".repeat(20_000)), 1], + [bzImageGzipFixture(), 0], + ] as const) { + const error = await cancelled(bytes, checks); + expect(error).toBeInstanceOf(ArtifactReaderFailure); + expect((error as ArtifactReaderFailure).reason).toBe("cancelled"); + } +}); + +it("honors an abort raised while the parse is running", async () => { + const input = inspectKernelBuildConfigInputSchema.parse({ + path: artifactPath, + }); + const artifact = { path: artifactPath, sha256: "ab".repeat(32), bytes: 0 }; + for (const bytes of [ + kernelBuildConfigFixture(), + bzImageGzipFixture(), + Buffer.from("Linux version ".repeat(300_000)), + ]) { + const controller = new AbortController(); + const pending = parseKernelBuildConfig( + bytes, + artifact, + input, + controller.signal, + ); + // Runs only once the parser gives the event loop a turn. + setImmediate(() => controller.abort(new Error("stop"))); + const error: unknown = await Promise.resolve(pending).then( + () => null, + (cause: unknown) => cause, + ); + expect(error).toBeInstanceOf(ArtifactReaderFailure); + expect((error as ArtifactReaderFailure).reason).toBe("cancelled"); + } +}); + +it("yields to the event loop while decoding and validating one long value", async () => { + const input = inspectKernelBuildConfigInputSchema.parse({ + path: artifactPath, + max_config_bytes: 16 << 20, + }); + const artifact = { path: artifactPath, sha256: "ab".repeat(32), bytes: 0 }; + const bytes = embeddedKernelConfig( + `CONFIG_EXTRA_FIRMWARE="${"a".repeat(8 << 20)}"\n`, + ); + // Each cancellation check records the event-loop turn it ran in. Checks + // that all share one turn could never observe a live abort. + let turns = 0; + let running = true; + const tick = (): void => { + turns += 1; + if (running) setImmediate(tick); + }; + setImmediate(tick); + const seen: number[] = []; + const signal = { + get aborted() { + seen.push(turns); + return false; + }, + reason: undefined, + } as unknown as AbortSignal; + try { + const report = await parseKernelBuildConfig(bytes, artifact, input, signal); + expect(report.config).toMatchObject({ status: "present", option_count: 1 }); + } finally { + running = false; + } + // Turns spent after the inflate returned, while the decoded text is parsed. + const parsing = new Set(seen.slice(seen.indexOf(seen[1] ?? -1))); + expect(parsing.size).toBeGreaterThanOrEqual(8); +}); + +it("does not keep the decoded config alive through a one-entry page", async () => { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + // Longer than V8's 13-character minimum for a sliced string. + const KEPT = "a page value long enough to be a slice"; + // About 0.9 MiB of text, below the size Node hands out as an external + // string, so the decoded text is counted in heapUsed. + const lines = Array.from( + { length: 3500 }, + (_, index) => + `CONFIG_OPTION_NUMBER_${String(index)}="${"v".repeat(240)}${String(index)}"`, + ); + const fixtures = Array.from({ length: 8 }, (_, page) => + embeddedKernelConfig( + [...lines, `CONFIG_PAGE_${String(page)}="${KEPT}"`, ""].join("\n"), + ), + ); + const pages = []; + gc(); + gc(); + const before = process.memoryUsage().heapUsed; + for (const [page, bytes] of fixtures.entries()) + pages.push(await parse(bytes, { name: `CONFIG_PAGE_${String(page)}` })); + gc(); + gc(); + const retained = process.memoryUsage().heapUsed - before; + expect(pages.map((report) => report.config.entries[0]?.value)).toEqual( + Array.from({ length: 8 }, () => KEPT), + ); + // Eight pages that each pinned their decoded text would hold about 7 MiB. + expect(retained).toBeLessThan(2 << 20); +}); + +it("yields to the event loop while marker searches find nothing", async () => { + const input = inspectKernelBuildConfigInputSchema.parse({ + path: artifactPath, + }); + const artifact = { path: artifactPath, sha256: "ab".repeat(32), bytes: 0 }; + // Every byte starts a possible IKCFG_ST, and no marker or banner exists. + const bytes = Buffer.alloc(8 << 20, 0x49); + let turns = 0; + let running = true; + const tick = (): void => { + turns += 1; + if (running) setImmediate(tick); + }; + setImmediate(tick); + const seen = new Set(); + const signal = { + get aborted() { + seen.add(turns); + return false; + }, + reason: undefined, + } as unknown as AbortSignal; + try { + const report = await parseKernelBuildConfig(bytes, artifact, input, signal); + expect(report.config.status).toBe("absent"); + expect(report.banner.status).toBe("absent"); + } finally { + running = false; + } + // Two 8 MiB searches with no match: checks must span many turns, not one. + expect(seen.size).toBeGreaterThanOrEqual(8); +}); + +it("finds markers that straddle a search window boundary", async () => { + for (const shift of [1, 4, 6]) { + // The first window ends needle.length - 1 bytes past each MiB, so these + // markers start inside one window and end in the next. + const at = (1 << 20) + shift; + const config = embeddedKernelConfig(); + const banner = Buffer.from("Linux version 6.1.0 (a@b) (gcc) #1\n"); + const bytes = Buffer.alloc((3 << 20) + config.length); + banner.copy(bytes, at); + config.copy(bytes, (2 << 20) + shift); + const report = await parse(bytes); + expect(report.banner).toMatchObject({ + status: "present", + location: { offset: at }, + }); + expect(report.config).toMatchObject({ + status: "present", + marker: { offset: (2 << 20) + shift }, + }); + } +}); + +it("retains incomplete coverage for a partial config end marker", async () => { + const bytes = kernelBuildConfigFixture(); + const end = bytes.indexOf( + Buffer.from("IKCFG_ED"), + bytes.indexOf(Buffer.from("IKCFG_ST")) + 20, + ); + const report = await parse(bytes.subarray(0, end + 7)); + expect(report.config.status).not.toBe("absent"); +}); + +it("ignores marker bytes inside a gzip member", async () => { + const bytes = Buffer.concat([ + kernelBuildConfigFixture().subarray(0, 64), + Buffer.from("IKCFG_ST"), + gzipSync(Buffer.from('CONFIG_LOCALVERSION="IKCFG_ED"\n'), { level: 0 }), + Buffer.from("IKCFG_ED"), + ]); + expect((await parse(bytes)).config.status).toBe("present"); +}); diff --git a/src/native/elf/KernelBuildConfig.ts b/src/native/elf/KernelBuildConfig.ts new file mode 100644 index 0000000000..29292a0811 --- /dev/null +++ b/src/native/elf/KernelBuildConfig.ts @@ -0,0 +1,990 @@ +import { createHash } from "node:crypto"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { crc32, inflateRaw } from "node:zlib"; + +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import { + KERNEL_BUILD_CONFIG_CODECS, + KERNEL_BUILD_CONFIG_NUMBER, + type InspectKernelBuildConfigInput, + type KernelBuildConfig, + type KernelBuildConfigEntry, +} from "../../domain/native/kernelBuildConfig.js"; +import { kernelCompressionCodecAt } from "./KernelCompressionCodec.js"; + +type Report = KernelBuildConfig; +type Entry = KernelBuildConfigEntry; +type Space = "file" | "inflated_payload"; +type Located = { space: Space; offset: number; bytes: number }; +type Reason = NonNullable; +type KernelBuildConfigBudgetKnob = + | "max_entries" + | "max_config_bytes" + | "max_payload_bytes"; + +const IKCFG_ST = Buffer.from("IKCFG_ST"); +const IKCFG_ED = Buffer.from("IKCFG_ED"); +const BANNER_PREFIX = Buffer.from("Linux version "); +const BANNER_TEXT_BYTES = 512; +/** IKCFG_ST + gzip candidates inflated per search; a kernel carries one. */ +const MAX_CONFIG_CANDIDATES = 16; +/** Total inflated bytes across all candidates, as a multiple of max_config_bytes. */ +const CONFIG_SEARCH_BUDGET_FACTOR = 4; +/** Bytes scanned between event-loop turns, so a live abort can be delivered. */ +const TURN_STRIDE = 0x100000; +const UNTRUSTED = "target-derived-untrusted" as const; + +const codeOf = (error: unknown): string | null => + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : null; + +/** + * Let pending signals and requests run, then honor an abort they raised. The + * scans are otherwise synchronous, so without a turn no abort could land. + */ +const yieldTurn = async (signal: AbortSignal | undefined): Promise => { + await nextTurn(); + assertArtifactReadActive(signal); +}; + +/** + * Offset of the next `needle` at or after `from`, or -1. Overlapping + * TURN_STRIDE windows are searched with a turn after each one that holds no + * match, so a search that finds nothing still yields once per MiB. + */ +const findNext = async ( + bytes: Buffer, + needle: Buffer, + from: number, + signal: AbortSignal | undefined, +): Promise => { + for (let start = from; ;) { + const end = Math.min(bytes.length, start + TURN_STRIDE + needle.length - 1); + const hit = bytes.subarray(0, end).indexOf(needle, start); + if (hit >= 0) return hit; + if (end >= bytes.length) return -1; + // The next window repeats the last needle.length - 1 bytes, so a match + // across the boundary is still found. + start = end - needle.length + 1; + await yieldTurn(signal); + } +}; + +/** SHA-256 hashed one TURN_STRIDE window per event-loop turn. */ +const sha256 = async ( + bytes: Buffer, + signal: AbortSignal | undefined, +): Promise => { + const hash = createHash("sha256"); + for (let start = 0; start < bytes.length; start += TURN_STRIDE) { + if (start > 0) await yieldTurn(signal); + hash.update(bytes.subarray(start, start + TURN_STRIDE)); + } + return hash.digest("hex"); +}; + +/** A caller-selected budget refused the image; `knob` names it as a typed fact. */ +export class KernelBuildConfigBudgetFailure extends ArtifactReaderFailure { + constructor( + readonly knob: KernelBuildConfigBudgetKnob, + limit: number, + ) { + super("limit", `Kernel build config exceeds ${knob} (${String(limit)}).`, { + boundary: "capacity", + }); + } +} + +const budget = (knob: KernelBuildConfigBudgetKnob, limit: number): never => { + throw new KernelBuildConfigBudgetFailure(knob, limit); +}; + +const isElf = (bytes: Buffer): boolean => + bytes.length >= 4 && + bytes[0] === 0x7f && + bytes[1] === 0x45 && + bytes[2] === 0x4c && + bytes[3] === 0x46; + +const codecAt = (bytes: Buffer, offset: number) => + kernelCompressionCodecAt(bytes, offset, KERNEL_BUILD_CONFIG_CODECS); + +const located = (space: Space, offset: number, bytes: number): Located => ({ + space, + offset, + bytes, +}); + +type GzipMember = + | { readonly kind: "member"; readonly output: Buffer; readonly end: number } + /** The deflate stream ended but the CRC-32 or ISIZE trailer does not match. */ + | { + readonly kind: "corrupt"; + readonly end: number; + readonly inflated: number; + } + /** + * `spent` bounds the output inflated before the failure: zero when only the + * header was read, otherwise the output budget, since zlib does not report + * partial output when it throws. + */ + | { readonly kind: "truncated"; readonly spent: number } + | { readonly kind: "invalid"; readonly spent: number } + /** The member inflates past `maxOutput`. */ + | { readonly kind: "limit" }; + +const CM_DEFLATE = 0x08; +const FTEXT_RESERVED = 0xe0; +const FHCRC = 0x02; +const FEXTRA = 0x04; +const FNAME = 0x08; +const FCOMMENT = 0x10; + +/** + * Offset after an RFC 1952 member header, null when the header ends early, or + * `invalid` when it is not deflate, sets reserved flags, or fails its FHCRC. + */ +const gzipHeaderEnd = ( + bytes: Buffer, + start: number, +): number | "invalid" | null => { + if (start + 10 > bytes.length) return null; + if (bytes[start + 2] !== CM_DEFLATE) return "invalid"; + const flags = bytes[start + 3] ?? 0; + if ((flags & FTEXT_RESERVED) !== 0) return "invalid"; + let cursor = start + 10; + if ((flags & FEXTRA) !== 0) { + if (cursor + 2 > bytes.length) return null; + cursor += 2 + bytes.readUInt16LE(cursor); + } + for (const flag of [FNAME, FCOMMENT]) { + if ((flags & flag) === 0) continue; + const zero = bytes.indexOf(0, cursor); + if (zero < 0) return null; + cursor = zero + 1; + } + if ((flags & FHCRC) === 0) return cursor > bytes.length ? null : cursor; + if (cursor + 2 > bytes.length) return null; + // FHCRC is the low 16 bits of the CRC-32 of every header byte before it. + if ( + bytes.readUInt16LE(cursor) !== + (crc32(bytes.subarray(start, cursor)) & 0xffff) + ) + return "invalid"; + return cursor + 2; +}; + +/** + * Inflate exactly one gzip member at `start` with bounded output, verifying its + * CRC-32 and ISIZE trailer. Only `bytes` is read, so the caller bounds the + * input by passing a subarray. Bytes after the member are not consumed, so the + * caller can require the next bytes to be a marker. + */ +interface Inflated { + readonly buffer: Buffer; + readonly engine: { readonly bytesWritten: number }; +} + +const inflateBounded = ( + input: Buffer, + maxOutputLength: number, +): Promise => + new Promise((resolve, reject) => { + inflateRaw(input, { maxOutputLength, info: true }, (error, result) => { + if (error === null) resolve(result as unknown as Inflated); + else reject(error); + }); + }); + +const inflateGzipMember = async ( + bytes: Buffer, + start: number, + maxOutput: number, + signal: AbortSignal | undefined, +): Promise => { + const body = gzipHeaderEnd(bytes, start); + if (body === null) return { kind: "truncated", spent: 0 }; + if (body === "invalid") return { kind: "invalid", spent: 0 }; + let output: Buffer; + let consumed: number; + try { + // zlib runs off the event loop, so an abort raised meanwhile is seen below. + const inflated = await inflateBounded(bytes.subarray(body), maxOutput); + output = inflated.buffer; + consumed = inflated.engine.bytesWritten; + } catch (error) { + assertArtifactReadActive(signal); + const code = codeOf(error); + if (code === "ERR_BUFFER_TOO_LARGE") return { kind: "limit" }; + return code === "Z_BUF_ERROR" + ? { kind: "truncated", spent: maxOutput } + : { kind: "invalid", spent: maxOutput }; + } + assertArtifactReadActive(signal); + const trailer = body + consumed; + if (trailer + 8 > bytes.length) + return { kind: "truncated", spent: output.length }; + if ( + bytes.readUInt32LE(trailer) !== crc32(output) || + bytes.readUInt32LE(trailer + 4) !== output.length >>> 0 + ) + return { kind: "corrupt", end: trailer + 8, inflated: output.length }; + return { kind: "member", output, end: trailer + 8 }; +}; + +const decoder = new TextDecoder("utf-8", { fatal: true }); +/** C0 controls other than tab, LF and CR, plus DEL. */ +const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; + +/** + * Strict UTF-8 text with no control bytes besides tab, LF and CR. The native + * decode is one linear pass; the control check then runs one TURN_STRIDE + * window per event-loop turn. + */ +const decodeText = async ( + bytes: Buffer, + signal: AbortSignal | undefined, +): Promise => { + let text: string; + try { + text = decoder.decode(bytes); + } catch { + return null; + } + for (let start = 0; start < text.length; start += TURN_STRIDE) { + if (start > 0) await yieldTurn(signal); + if (CONTROL.test(text.slice(start, start + TURN_STRIDE))) return null; + } + return text; +}; + +const QUOTE = 0x22; +const BACKSLASH = 0x5c; + +/** Line terminators other than LF that a `.config` value may not contain. */ +const isLineBreak = (char: number): boolean => + char === 0x0d || char === 0x2028 || char === 0x2029; + +/** + * Decoded `"..."` value whose only escapes are `\"` and `\\`. The line is + * validated in place first, yielding once per TURN_STRIDE characters, so the + * decoded string is built once, no larger than the line, and only for a valid + * value. + */ +const unquote = async ( + raw: string, + signal: AbortSignal | undefined, +): Promise => { + if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"')) return null; + const end = raw.length - 1; + let escaped = false; + let turnAt = TURN_STRIDE; + for (let index = 1; index < end; index += 1) { + if (index >= turnAt) { + turnAt = index + TURN_STRIDE; + await yieldTurn(signal); + } + const char = raw.charCodeAt(index); + if (char === QUOTE || isLineBreak(char)) return null; + if (char !== BACKSLASH) continue; + const next = raw.charCodeAt(index + 1); + if (index + 1 >= end || (next !== QUOTE && next !== BACKSLASH)) return null; + escaped = true; + index += 1; + } + const body = raw.slice(1, end); + return escaped ? body.replace(/\\(["\\])/gu, "$1") : body; +}; + +const classifyValue = async ( + raw: string, + signal: AbortSignal | undefined, +): Promise<{ kind: Entry["kind"]; value: string } | null> => { + if (raw === "y" || raw === "m") return { kind: raw, value: raw }; + if (KERNEL_BUILD_CONFIG_NUMBER.test(raw)) + return { kind: "number", value: raw }; + // Kconfig strings have no length limit; max_config_bytes bounds the line. + const text = await unquote(raw, signal); + return text === null ? null : { kind: "string", value: text }; +}; + +/** Config names are bounded by the inflated config, not an independent name cap. */ +const validConfigName = async ( + name: string, + signal: AbortSignal | undefined, +): Promise => { + if (!name.startsWith("CONFIG_") || name.length === 7) return false; + for (let start = 7; start < name.length; start += TURN_STRIDE) { + if (start > 7) await yieldTurn(signal); + if (!/^[A-Za-z0-9_]+$/u.test(name.slice(start, start + TURN_STRIDE))) + return false; + } + return true; +}; + +/** + * Strict `.config` grammar: every non-blank line is a comment, a + * `# CONFIG_X is not set` line, or `CONFIG_X=value`. Anything else rejects the + * whole candidate so ordinary marker strings never read as a config. Lines + * are scanned one at a time, so no per-line array is built ahead of the + * max_entries check. + */ +const parseConfigText = async ( + bytes: Buffer, + maxEntries: number, + signal: AbortSignal | undefined, +): Promise => { + const text = await decodeText(bytes, signal); + if (text === null || text.length === 0) return null; + const entries: Entry[] = []; + const push = (entry: Entry): void => { + if (entries.length >= maxEntries) budget("max_entries", maxEntries); + entries.push(entry); + }; + let lines = 0; + let turnAt = TURN_STRIDE; + for (let start = 0; start < text.length;) { + if ((lines++ & 0x3ff) === 0) assertArtifactReadActive(signal); + if (start >= turnAt) { + turnAt = start + TURN_STRIDE; + await yieldTurn(signal); + } + const newline = text.indexOf("\n", start); + const end = newline < 0 ? text.length : newline; + const line = text.slice( + start, + end > start && text.charCodeAt(end - 1) === 0x0d ? end - 1 : end, + ); + start = end + 1; + if (/^[ \t]*$/u.test(line)) continue; + if (line.startsWith("#")) { + if (line.startsWith("# CONFIG_") && line.endsWith(" is not set")) { + const name = line.slice(2, -11); + if (await validConfigName(name, signal)) + push({ name, kind: "not_set", value: null, trust: UNTRUSTED }); + } + continue; + } + const separator = line.indexOf("="); + if (separator < 0) return null; + const name = line.slice(0, separator); + if (!(await validConfigName(name, signal))) return null; + const value = await classifyValue(line.slice(separator + 1), signal); + if (value === null) return null; + push({ name, kind: value.kind, value: value.value, trust: UNTRUSTED }); + } + return entries.length === 0 ? null : entries; +}; + +interface ConfigBlob { + readonly marker: number; + readonly gzipStart: number; + readonly memberBytes: number; +} + +interface AcceptedConfig extends ConfigBlob { + readonly kind: "present"; + readonly entries: readonly Entry[]; + readonly sha256: string; +} + +/** + * `IKCFG_ST`, gzip magic, and `IKCFG_ED` identify a config blob whose member + * does not decode, fails its trailer check, or whose text fails the grammar. + * For a member that does not decode, `memberBytes` runs to the closing marker. + */ +interface MalformedConfig extends ConfigBlob { + readonly kind: "malformed"; + readonly reason: "config_gzip_invalid" | "config_text_invalid"; +} + +/** + * The first accepted config wins. Otherwise a framed but unreadable blob, + * including a deflate stream that is damaged or cut off by its closing marker, + * is malformed and a member cut off by the searched bytes is truncated; neither + * is reported as absent. Each candidate's inflate input ends at the next + * `IKCFG_ED`, since `kernel/configs.c` places that marker right after the + * member. At most MAX_CONFIG_CANDIDATES members and CONFIG_SEARCH_BUDGET_FACTOR + * times `max_config_bytes` of inflated output are spent; past that the search + * is `exhausted` rather than silently stopped. + */ +const findConfig = async ( + bytes: Buffer, + maxOutput: number, + maxEntries: number, + signal: AbortSignal | undefined, +): Promise< + AcceptedConfig | MalformedConfig | "absent" | "truncated" | "exhausted" +> => { + let from = 0; + let truncated = false; + let malformed: MalformedConfig | null = null; + let candidates = 0; + let remaining = maxOutput * CONFIG_SEARCH_BUDGET_FACTOR; + let oversized = false; + // Next IKCFG_ED at or after the current candidate; -1 once none remains. + let closing: number | null = null; + let turnAt = TURN_STRIDE; + for (;;) { + assertArtifactReadActive(signal); + const marker = await findNext(bytes, IKCFG_ST, from, signal); + if (marker < 0) break; + if (marker >= turnAt) { + turnAt = marker + TURN_STRIDE; + await yieldTurn(signal); + } + from = marker + 1; + const gzipAt = marker + IKCFG_ST.length; + if (codecAt(bytes, gzipAt) !== "gzip") continue; + if (closing === null || (closing >= 0 && closing < gzipAt)) + closing = await findNext(bytes, IKCFG_ED, gzipAt, signal); + // Without a closing marker nothing later can be framed; one truncated + // member is enough to report the search incomplete. + if (closing < 0 && truncated) break; + if (candidates >= MAX_CONFIG_CANDIDATES || remaining === 0) + return "exhausted"; + candidates += 1; + const limit = Math.min(maxOutput, remaining); + const member = await inflateGzipMember(bytes, gzipAt, limit, signal); + if (member.kind === "limit") { + if (limit < maxOutput) return "exhausted"; + // A member past max_config_bytes may be a decoy, so a later framed + // config can still win; the budget failure is raised only if none does. + oversized = true; + remaining -= limit; + continue; + } + if (member.kind === "member") remaining -= member.output.length; + if (member.kind === "corrupt") remaining -= member.inflated; + if (member.kind === "truncated" || member.kind === "invalid") { + remaining -= member.spent; + if (closing < 0) { + if (member.kind === "truncated") truncated = true; + continue; + } + // IKCFG_ST, gzip magic and a later IKCFG_ED frame bytes that do not + // decode: an unreadable config, not an absent one. + malformed ??= { + kind: "malformed", + marker, + gzipStart: gzipAt, + memberBytes: closing - gzipAt, + reason: "config_gzip_invalid", + }; + continue; + } + if ( + !bytes.subarray(member.end, member.end + IKCFG_ED.length).equals(IKCFG_ED) + ) { + const suffix = bytes.subarray(member.end); + if ( + suffix.length < IKCFG_ED.length && + IKCFG_ED.subarray(0, suffix.length).equals(suffix) + ) + truncated = true; + continue; + } + const blob = { + marker, + gzipStart: gzipAt, + memberBytes: member.end - gzipAt, + }; + const entries = + member.kind === "member" + ? await parseConfigText(member.output, maxEntries, signal) + : null; + if (member.kind === "member" && entries !== null) + return { + kind: "present", + ...blob, + entries, + sha256: await sha256(member.output, signal), + }; + malformed ??= { + kind: "malformed", + ...blob, + reason: + member.kind === "corrupt" + ? "config_gzip_invalid" + : "config_text_invalid", + }; + } + if (oversized) budget("max_config_bytes", maxOutput); + return malformed ?? (truncated ? "truncated" : "absent"); +}; + +const parseBannerText = ( + text: string, +): { + version: string; + builder: string | null; + compiler: string | null; +} | null => { + let rest = text.slice(BANNER_PREFIX.length); + const version = /^[0-9][0-9A-Za-z.+_~-]*/u.exec(rest)?.[0]; + if (version === undefined) return null; + rest = rest.slice(version.length); + let builder: string | null = null; + let compiler: string | null = null; + if (rest.startsWith(" (")) { + const close = rest.indexOf(")"); + if (close > 2) { + builder = rest.slice(2, close); + rest = rest.slice(close + 1); + } + } + if (rest.startsWith(" (")) { + const marked = rest.lastIndexOf(") #"); + if (marked > 2) compiler = rest.slice(2, marked); + else if (rest.endsWith(")") && rest.length > 3) + compiler = rest.slice(2, -1); + } + return { version, builder, compiler }; +}; + +const absentBanner = (status: "absent" | "unknown"): Report["banner"] => ({ + status, + text: null, + version: null, + builder: null, + compiler: null, + location: null, + trust: UNTRUSTED, +}); + +const isPrintable = (value: number): boolean => value >= 0x20 && value <= 0x7e; + +/** + * First printable, NUL- or newline-terminated `Linux version ...` + * string of at most BANNER_TEXT_BYTES. Every hit inside one printable run + * shares that run's terminator, so each run is scanned once and the search + * resumes after it: the scan is linear in the searched bytes. + */ +const findBanner = async ( + bytes: Buffer, + space: Space, + signal: AbortSignal | undefined, +): Promise => { + let from = 0; + let turnAt = TURN_STRIDE; + for (;;) { + assertArtifactReadActive(signal); + const at = await findNext(bytes, BANNER_PREFIX, from, signal); + if (at < 0) return absentBanner("absent"); + let end = at + BANNER_PREFIX.length; + while (end < bytes.length && isPrintable(bytes[end] ?? 0)) { + end += 1; + if (end % TURN_STRIDE === 0) await yieldTurn(signal); + } + if (end >= turnAt) { + turnAt = end + TURN_STRIDE; + await yieldTurn(signal); + } + from = end + 1; + if (end >= bytes.length || (bytes[end] !== 0 && bytes[end] !== 0x0a)) + continue; + // Only hits within BANNER_TEXT_BYTES of the terminator fit the window. + const run = bytes.subarray(0, end); + for ( + let hit = run.indexOf( + BANNER_PREFIX, + Math.max(at, end - BANNER_TEXT_BYTES), + ); + hit >= 0; + hit = run.indexOf(BANNER_PREFIX, hit + 1) + ) { + const first = bytes[hit + BANNER_PREFIX.length] ?? 0; + if (first < 0x30 || first > 0x39) continue; + const text = bytes.toString("latin1", hit, end); + const parsed = parseBannerText(text); + if (parsed === null) continue; + return { + status: "present", + text, + version: parsed.version, + builder: parsed.builder, + compiler: parsed.compiler, + location: located(space, hit, end - hit), + trust: UNTRUSTED, + }; + } + } +}; + +const baseLimitations = (): string[] => [ + "This workflow does not execute the image or boot the kernel.", + "Banner text and configuration values are target-derived and untrusted.", + "A config is accepted only when IKCFG_ST is immediately followed by a gzip member that inflates to config text and is immediately followed by IKCFG_ED. An IKCFG_ST string without gzip magic is not a config. A framed member that does not decode, fails its trailer check, or fails the config grammar is malformed, not absent.", + "An absent banner or config describes only the bytes that were searched; a kernel built without CONFIG_IKCONFIG carries no embedded config.", + "Only a gzip payload inside an x86 bzImage setup header is inflated. Other codecs are named and left compressed.", +]; + +const emptyConfig = ( + input: InspectKernelBuildConfigInput, + status: Report["config"]["status"], + reason: Reason | null, +): Report["config"] => ({ + status, + reason, + marker: null, + end_marker: null, + gzip: null, + sha256: null, + option_count: 0, + matched_count: 0, + offset: input.offset, + limit: input.limit, + name: input.name ?? null, + entries: [], +}); + +const blobRanges = ( + blob: ConfigBlob, + space: Space, +): Pick => ({ + marker: located(space, blob.marker, IKCFG_ST.length), + gzip: located(space, blob.gzipStart, blob.memberBytes), + end_marker: located( + space, + blob.gzipStart + blob.memberBytes, + IKCFG_ED.length, + ), +}); + +/** + * A standalone copy of `value`. Names and values are slices of the decoded + * config text, and a V8 slice keeps that whole text alive, so a page must not + * hand them out directly. + */ +const detached = (value: string): string => + Buffer.from(value, "utf8").toString("utf8"); + +const acceptedConfig = ( + found: AcceptedConfig, + input: InspectKernelBuildConfigInput, + space: Space, +): Report["config"] => { + const matched = + input.name === undefined + ? found.entries + : found.entries.filter((entry) => entry.name === input.name); + return { + status: "present", + reason: null, + ...blobRanges(found, space), + sha256: found.sha256, + option_count: found.entries.length, + matched_count: matched.length, + offset: input.offset, + limit: input.limit, + name: input.name ?? null, + entries: matched + .slice(input.offset, input.offset + input.limit) + .map((entry) => ({ + ...entry, + name: detached(entry.name), + value: entry.value === null ? null : detached(entry.value), + })), + }; +}; + +interface SearchSpace { + readonly bytes: Buffer; + readonly space: Space; + readonly payload: Report["payload"]; + readonly extraLimitations: readonly string[]; +} + +const reportFromSearch = async ( + artifact: Report["artifact"], + format: Report["format"], + input: InspectKernelBuildConfigInput, + search: SearchSpace, + signal: AbortSignal | undefined, +): Promise => { + const found = await findConfig( + search.bytes, + input.max_config_bytes, + input.max_entries, + signal, + ); + const config = + found === "truncated" + ? emptyConfig(input, "incomplete", "config_gzip_truncated") + : found === "exhausted" + ? emptyConfig(input, "incomplete", "config_search_exhausted") + : found === "absent" + ? emptyConfig(input, "absent", null) + : found.kind === "malformed" + ? { + ...emptyConfig(input, "malformed", found.reason), + ...blobRanges(found, search.space), + } + : acceptedConfig(found, input, search.space); + const complete = found === "absent" || config.status === "present"; + const banner = await findBanner(search.bytes, search.space, signal); + return { + artifact, + format, + payload: search.payload, + next_probe: null, + banner, + config, + coverage: { + status: complete ? "complete" : "incomplete", + examined_bytes: search.bytes.length, + examined_entries: config.option_count, + }, + limitations: [ + ...baseLimitations(), + ...search.extraLimitations, + ...(found === "truncated" + ? [ + "An IKCFG_ST gzip member runs past the searched bytes. The config is incomplete and is not reported as absent.", + ] + : []), + ...(found === "exhausted" + ? [ + `The config search stopped after ${String(MAX_CONFIG_CANDIDATES)} IKCFG_ST gzip candidates or ${String(CONFIG_SEARCH_BUDGET_FACTOR)} times max_config_bytes of inflated output without accepting a config. Candidates after that point were not inflated, so the config is incomplete and is not reported as absent.`, + ] + : []), + ...(config.status === "malformed" + ? [ + config.reason === "config_gzip_invalid" + ? "An IKCFG_ST gzip member framed by IKCFG_ED does not decode or fails its CRC-32 or ISIZE check. The embedded config is unreadable and is not reported as absent." + : "An IKCFG_ST gzip member framed by IKCFG_ED inflates to text that is not a .config. The embedded config is unreadable and is not reported as absent.", + ] + : []), + ], + }; +}; + +interface BzImagePayload { + readonly offset: number; + readonly bytes: number; +} + +/** x86 boot protocol: `HdrS` at 0x202, payload fields from version 2.08. */ +const readBzImage = ( + bytes: Buffer, +): + | { kind: "none" } + | { kind: "unavailable" } + | { kind: "truncated"; payload: BzImagePayload } + | { kind: "payload"; payload: BzImagePayload } => { + if (bytes.length < 0x206 || bytes.toString("latin1", 0x202, 0x206) !== "HdrS") + return { kind: "none" }; + if (bytes.length < 0x250 || bytes.readUInt16LE(0x206) < 0x0208) + return { kind: "unavailable" }; + const setupSects = bytes[0x1f1] === 0 ? 4 : (bytes[0x1f1] ?? 4); + const payload = { + offset: (setupSects + 1) * 512 + bytes.readUInt32LE(0x248), + bytes: bytes.readUInt32LE(0x24c), + }; + if (payload.bytes === 0) return { kind: "unavailable" }; + return payload.offset > bytes.length || + payload.bytes > bytes.length - payload.offset + ? { kind: "truncated", payload } + : { kind: "payload", payload }; +}; + +const closedReport = ( + artifact: Report["artifact"], + format: Report["format"], + input: InspectKernelBuildConfigInput, + payload: Report["payload"], + reason: Reason, + status: "incomplete" | "unsupported", + limitation: string, +): Report => ({ + artifact, + format, + payload, + next_probe: + status === "unsupported" + ? { + operation: "external_decompression", + rationale: `Decompress this ${payload.codec ?? "unrecognized"} payload with an external utility into a separate file, then call inspect_kernel_build_config on that file. REA inflates only a gzip payload inside an x86 bzImage.`, + } + : null, + banner: absentBanner("unknown"), + config: emptyConfig(input, status, reason), + coverage: { status, examined_bytes: 0, examined_entries: 0 }, + limitations: [...baseLimitations(), limitation], +}); + +const NOT_SEARCHED = + "The banner and embedded config were not searched and are not reported as absent."; + +const bzImageReport = async ( + bytes: Buffer, + artifact: Report["artifact"], + input: InspectKernelBuildConfigInput, + header: Exclude, { kind: "none" }>, + signal: AbortSignal | undefined, +): Promise => { + if (header.kind === "unavailable") + return closedReport( + artifact, + "file", + input, + { + container: "bzimage", + codec: null, + status: "unavailable", + offset: null, + bytes: null, + inflated_bytes: null, + }, + "setup_header_payload_unavailable", + "incomplete", + `The x86 setup header does not carry a usable payload_offset and payload_length (boot protocol 2.08 or later). ${NOT_SEARCHED}`, + ); + if (header.kind === "truncated") + return closedReport( + artifact, + "file", + input, + { + container: "bzimage", + codec: null, + status: "truncated", + offset: null, + bytes: null, + inflated_bytes: null, + }, + "payload_truncated", + "incomplete", + `The bzImage payload declared by the setup header extends past the file. ${NOT_SEARCHED}`, + ); + const { payload } = header; + // Detection and inflation read only the declared payload range. + const body = bytes.subarray(payload.offset, payload.offset + payload.bytes); + const codec = codecAt(body, 0); + const base = { + container: "bzimage" as const, + offset: payload.offset, + bytes: payload.bytes, + }; + if (codec !== "gzip") + return closedReport( + artifact, + "file", + input, + { ...base, codec, status: "unsupported", inflated_bytes: null }, + codec === null + ? "unrecognized_payload_codec" + : "payload_codec_unsupported", + "unsupported", + `The bzImage payload codec is ${codec ?? "not recognized"} and was not inflated. ${NOT_SEARCHED}`, + ); + // Only the leading gzip member is inflated; its own ISIZE trailer carries + // the length. Bytes after the member inside the payload are not read. + assertArtifactReadActive(signal); + const member = await inflateGzipMember( + body, + 0, + input.max_payload_bytes, + signal, + ); + if (member.kind === "limit") + budget("max_payload_bytes", input.max_payload_bytes); + if (member.kind !== "member") + return closedReport( + artifact, + "file", + input, + { + ...base, + codec, + status: member.kind === "truncated" ? "truncated" : "malformed", + inflated_bytes: null, + }, + member.kind === "truncated" + ? "payload_truncated" + : "payload_gzip_invalid", + "incomplete", + member.kind === "truncated" + ? `The bzImage gzip payload ends before its gzip member does. ${NOT_SEARCHED}` + : `The bzImage gzip payload is not a valid gzip member or fails its CRC-32 or ISIZE check. ${NOT_SEARCHED}`, + ); + return reportFromSearch( + artifact, + "file", + input, + { + bytes: member.output, + space: "inflated_payload", + payload: { + ...base, + codec, + status: "inflated", + inflated_bytes: member.output.length, + }, + extraLimitations: [ + "Banner and config locations with space inflated_payload are offsets in the inflated bzImage payload, not file offsets.", + ], + }, + signal, + ); +}; + +/** + * Read a kernel version banner and an embedded IKCFG build config. + * The caller supplies the already bounded file bytes. Scans yield to the event + * loop once per TURN_STRIDE bytes and inflation runs off it, so an abort raised + * while parsing is honored at the next check. One inflate call runs to its + * output bound once started. + */ +export const parseKernelBuildConfig = async ( + bytes: Buffer, + artifact: Report["artifact"], + input: InspectKernelBuildConfigInput, + signal?: AbortSignal, +): Promise => { + const elf = isElf(bytes); + if (!elf) { + const header = readBzImage(bytes); + if (header.kind !== "none") + return bzImageReport(bytes, artifact, input, header, signal); + const whole = codecAt(bytes, 0); + if (whole !== null) + return closedReport( + artifact, + "file", + input, + { + container: "file", + codec: whole, + status: "unsupported", + offset: 0, + bytes: bytes.length, + inflated_bytes: null, + }, + "payload_codec_unsupported", + "unsupported", + `This file starts with a ${whole} stream and has no x86 setup header. It was not inflated. ${NOT_SEARCHED}`, + ); + } + return reportFromSearch( + artifact, + elf ? "elf" : "file", + input, + { + bytes, + space: "file", + payload: { + container: "file", + codec: null, + status: "uncompressed", + offset: null, + bytes: null, + inflated_bytes: null, + }, + extraLimitations: [], + }, + signal, + ); +}; diff --git a/src/native/elf/KernelBuildConfigProvider.ts b/src/native/elf/KernelBuildConfigProvider.ts new file mode 100644 index 0000000000..0075332321 --- /dev/null +++ b/src/native/elf/KernelBuildConfigProvider.ts @@ -0,0 +1,244 @@ +import { + projectStableArtifactFailure, + stableArtifactInputRecovery, + projectArtifactCloseFailure, +} from "../../artifacts/StableArtifactFailure.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { KernelBuildConfigPort } from "../../application/binaryDiagnostics/KernelBuildConfigPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { + MAX_INSPECT_KERNEL_BUILD_CONFIG_CONFIG_BYTES, + MAX_INSPECT_KERNEL_BUILD_CONFIG_ENTRIES, + MAX_INSPECT_KERNEL_BUILD_CONFIG_FILE_BYTES, + MAX_INSPECT_KERNEL_BUILD_CONFIG_PAYLOAD_BYTES, + type InspectKernelBuildConfigInput, +} from "../../domain/native/kernelBuildConfig.js"; +import { err, ok } from "../../domain/result.js"; +import { KERNEL_BUILD_CONFIG_PROVIDER } from "../../domain/investigationProviders.js"; +import { + KernelBuildConfigBudgetFailure, + parseKernelBuildConfig, +} from "./KernelBuildConfig.js"; + +const OPERATION = "inspect_kernel_build_config"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class KernelBuildConfigProvider implements KernelBuildConfigPort { + readonly identity = KERNEL_BUILD_CONFIG_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + KERNEL_BUILD_CONFIG_PROVIDER.id, + OPERATION, + ); + } + } + + async inspect( + input: InspectKernelBuildConfigInput, + options?: ExecutionOptions, + ) { + try { + return await this.#resources.run(async () => { + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + await parseKernelBuildConfig( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + options?.signal, + ), + ); + }); + } catch (cause) { + return err(kernelBuildConfigFailure(cause, input)); + } + } +} + +const ceiling = ( + knob: string, + maximum: number, + selected: number, + description: string, +): string => + selected >= maximum + ? `The image exceeds the ${String(maximum)} ${knob} ceiling this operation accepts; select a smaller artifact.` + : `Raise ${knob} above ${String(selected)} or select a smaller artifact; the limit is the caller-selected ${description}.`; + +const inputBudgetRemediation = ( + boundary: "file-size" | "capacity", + input: InspectKernelBuildConfigInput, + cause: ArtifactReaderFailure, +): string => { + if (boundary === "file-size") + return input.max_file_bytes >= MAX_INSPECT_KERNEL_BUILD_CONFIG_FILE_BYTES + ? `The image exceeds the ${String(MAX_INSPECT_KERNEL_BUILD_CONFIG_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + const knob = + cause instanceof KernelBuildConfigBudgetFailure ? cause.knob : null; + if (knob === "max_entries") + return ceiling( + knob, + MAX_INSPECT_KERNEL_BUILD_CONFIG_ENTRIES, + input.max_entries, + "CONFIG_* entry budget", + ); + if (knob === "max_config_bytes") + return ceiling( + knob, + MAX_INSPECT_KERNEL_BUILD_CONFIG_CONFIG_BYTES, + input.max_config_bytes, + "inflated IKCFG gzip budget", + ); + if (knob === "max_payload_bytes") + return ceiling( + knob, + MAX_INSPECT_KERNEL_BUILD_CONFIG_PAYLOAD_BYTES, + input.max_payload_bytes, + "inflated bzImage payload budget", + ); + return "Select a smaller artifact. The caller-selected kernel build config budgets refused this image."; +}; + +const kernelBuildConfigFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectKernelBuildConfigInput, +) => { + const { cause, origin } = facts; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.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 new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_entries: input.max_entries, + max_config_bytes: input.max_config_bytes, + max_payload_bytes: input.max_payload_bytes, + }, + { + cause, + ...(cause.boundary !== "capacity" && cause.boundary !== "file-size" + ? {} + : { + retryable: false as const, + remediationAction: inputBudgetRemediation( + cause.boundary, + input, + cause, + ), + }), + }, + ); + } + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(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, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected kernel image could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", + undefined, + cause instanceof Error + ? cause.message + : "Kernel build config inspection failed.", + { cause }, + ); +}; + +const kernelBuildConfigFailure = ( + cause: unknown, + input: InspectKernelBuildConfigInput, +) => { + const primary = kernelBuildConfigFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + return projectStableArtifactFailure( + cause, + primary, + input.path, + KERNEL_BUILD_CONFIG_PROVIDER.id, + OPERATION, + ); +}; diff --git a/src/native/elf/KernelCompressionCodec.test.ts b/src/native/elf/KernelCompressionCodec.test.ts new file mode 100644 index 0000000000..66f2449612 --- /dev/null +++ b/src/native/elf/KernelCompressionCodec.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "vitest"; +import { kernelCompressionCodecAt } from "./KernelCompressionCodec.js"; + +const ALL = ["gzip", "bzip2", "lzma", "xz", "lzo", "lz4", "zstd"] as const; + +it.each([ + ["gzip", [0x1f, 0x8b]], + ["bzip2", [0x42, 0x5a, 0x68]], + ["lzma", [0x5d, 0x00, 0x00]], + ["xz", [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]], + ["lzo", [0x89, 0x4c, 0x5a]], + ["lz4", [0x02, 0x21, 0x4c, 0x18]], + ["zstd", [0x28, 0xb5, 0x2f, 0xfd]], +] as const)("names a %s stream at an offset", (codec, magic) => { + const bytes = Buffer.concat([Buffer.from("pad"), Buffer.from(magic)]); + expect(kernelCompressionCodecAt(bytes, 3, ALL)).toBe(codec); + expect(kernelCompressionCodecAt(bytes, 0, ALL)).toBeNull(); +}); + +it("names only the accepted codecs and never reads past the bytes", () => { + const lzma = Buffer.from([0x5d, 0x00, 0x00, 0x80]); + expect(kernelCompressionCodecAt(lzma, 0, ["gzip", "xz", "zstd"])).toBeNull(); + expect(kernelCompressionCodecAt(lzma, 0, ["lzma"])).toBe("lzma"); + expect( + kernelCompressionCodecAt(Buffer.from([0xfd, 0x37, 0x7a]), 0, ALL), + ).toBeNull(); +}); diff --git a/src/native/elf/KernelCompressionCodec.ts b/src/native/elf/KernelCompressionCodec.ts new file mode 100644 index 0000000000..92a39b02ad --- /dev/null +++ b/src/native/elf/KernelCompressionCodec.ts @@ -0,0 +1,44 @@ +/** Codecs Linux kernel images and modules are compressed with. */ +export type KernelCompressionCodec = + | "gzip" + | "bzip2" + | "lzma" + | "xz" + | "lzo" + | "lz4" + | "zstd"; + +/** + * Stream magic numbers in the order `lib/decompress.c` checks them. gzip is + * the RFC 1952 ID1/ID2 pair; the compression method byte is left to the gzip + * reader. The lzma, bzip2, and lz4 magics are longer than the kernel's own + * 2-byte checks so arbitrary bytes are less likely to be named as a codec. + */ +const MAGICS: readonly { + readonly codec: KernelCompressionCodec; + readonly magic: Buffer; +}[] = [ + { codec: "gzip", magic: Buffer.from([0x1f, 0x8b]) }, + { codec: "bzip2", magic: Buffer.from("BZh") }, + { codec: "lzma", magic: Buffer.from([0x5d, 0x00, 0x00]) }, + { codec: "xz", magic: Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]) }, + { codec: "lzo", magic: Buffer.from([0x89, 0x4c, 0x5a]) }, + { codec: "lz4", magic: Buffer.from([0x02, 0x21, 0x4c, 0x18]) }, + { codec: "zstd", magic: Buffer.from([0x28, 0xb5, 0x2f, 0xfd]) }, +]; + +/** The first of `accepted` whose magic starts at `offset`, or null. */ +export const kernelCompressionCodecAt = ( + bytes: Buffer, + offset: number, + accepted: readonly C[], +): C | null => { + for (const { codec, magic } of MAGICS) + if ( + (accepted as readonly KernelCompressionCodec[]).includes(codec) && + offset + magic.length <= bytes.length && + bytes.subarray(offset, offset + magic.length).equals(magic) + ) + return codec as C; + return null; +}; diff --git a/src/native/elf/KernelModuleParser.ts b/src/native/elf/KernelModuleParser.ts index b24878bb20..9d41244303 100644 --- a/src/native/elf/KernelModuleParser.ts +++ b/src/native/elf/KernelModuleParser.ts @@ -19,15 +19,14 @@ import type { BinaryContainer, } from "../metadata/BinaryContainer.js"; import { readElfImage } from "../metadata/ElfImage.js"; +import { kernelCompressionCodecAt } from "./KernelCompressionCodec.js"; type ElfSection = Extract; type Report = KernelModule; type Limitations = string[]; const MAGIC = Buffer.from(MODULE_SIGNATURE_MAGIC, "latin1"); -const GZIP = Buffer.from([0x1f, 0x8b]); -const XZ = Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]); -const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); +const MODULE_CODECS = ["gzip", "xz", "zstd"] as const; const sha256 = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); @@ -71,16 +70,8 @@ const rawHex = (bytes: Buffer): string | null => bytes.length > MAX_KERNEL_MODULE_RAW_BYTES ? null : bytes.toString("hex"); const compressionCodec = (bytes: Buffer): Report["compression"] => { - if (bytes.subarray(0, GZIP.length).equals(GZIP)) - return { codec: "gzip", status: "unsupported" }; - if (bytes.length >= XZ.length && bytes.subarray(0, XZ.length).equals(XZ)) - return { codec: "xz", status: "unsupported" }; - if ( - bytes.length >= ZSTD.length && - bytes.subarray(0, ZSTD.length).equals(ZSTD) - ) - return { codec: "zstd", status: "unsupported" }; - return null; + const codec = kernelCompressionCodecAt(bytes, 0, MODULE_CODECS); + return codec === null ? null : { codec, status: "unsupported" }; }; const baseLimitations = (): Limitations => [ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 626a65ef2a..6399f3f443 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -21,6 +21,7 @@ import { createByteEntropyService } from "../composition/binaryDiagnostics.js"; import { registerElfNotesTool } from "./registerElfNotesTool.js"; import { registerAppleIpsTool } from "./registerAppleIpsTool.js"; import { registerKernelModuleTool } from "./registerKernelModuleTool.js"; +import { registerKernelBuildConfigTool } from "./registerKernelBuildConfigTool.js"; import { registerPeLoadConfigTool } from "./registerPeLoadConfigTool.js"; import { registerBinaryHardeningTool } from "./registerBinaryHardeningTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; @@ -221,6 +222,7 @@ export const createServer = ( elfNotes, appleIps, kernelModule, + kernelBuildConfig, peLoadConfig, binaryHardening, peCertificates, @@ -513,6 +515,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerKernelBuildConfigTool( + server, + kernelBuildConfig, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerPeLoadConfigTool( server, peLoadConfig, diff --git a/src/server/registerKernelBuildConfigTool.ts b/src/server/registerKernelBuildConfigTool.ts new file mode 100644 index 0000000000..23f2d8812c --- /dev/null +++ b/src/server/registerKernelBuildConfigTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { KernelBuildConfigService } from "../application/binaryDiagnostics/KernelBuildConfigService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file Linux kernel banner and build-config workflow and retain its complete Evidence. */ +export const registerKernelBuildConfigTool = ( + server: EvidenceMcpServer, + service: KernelBuildConfigService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_kernel_build_config"); + 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/kernelBuildConfigCli.test.ts b/tests/acceptance/kernelBuildConfigCli.test.ts new file mode 100644 index 0000000000..739f3e524d --- /dev/null +++ b/tests/acceptance/kernelBuildConfigCli.test.ts @@ -0,0 +1,90 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + bzImageGzipFixture, + bzImageXzFixture, + kernelBuildConfigFixture, +} from "../../src/native/elf/KernelBuildConfig.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const contract = toolContract("inspect_kernel_build_config"); + +cliTest( + "reports the banner and one filtered config option through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-kernel-config-cli-"); + const path = join(root, "vmlinux"); + await writeFile(path, kernelBuildConfigFixture()); + const result = await cli.run({ + arguments: [ + "inspect-kernel-build-config", + path, + "--name", + "CONFIG_SECURITY_SELINUX", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + expect(evidence.normalized_result.banner.version).toBe("6.6.0"); + expect(evidence.normalized_result.config).toMatchObject({ + status: "present", + option_count: 9, + matched_count: 1, + entries: [{ name: "CONFIG_SECURITY_SELINUX", kind: "y", value: "y" }], + }); + expect(evidence.subject?.format).toBe("elf"); + }, +); + +cliTest( + "resolves a relative bzImage path and pages its inflated config", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-kernel-config-cli-rel-"); + await writeFile(join(root, "bzImage"), bzImageGzipFixture()); + const result = await cli.run({ + arguments: [ + "inspect-kernel-build-config", + "bzImage", + "--offset", + "8", + "--limit", + "1", + "--json", + ], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + expect(evidence.normalized_result.payload).toMatchObject({ + container: "bzimage", + status: "inflated", + }); + expect( + evidence.normalized_result.config.entries.map((entry) => entry.name), + ).toEqual(["CONFIG_MODULE_SIG_FORCE"]); + expect(evidence.subject?.local_path).toBe(join(root, "bzImage")); + }, +); + +cliTest( + "names an xz payload as unsupported without claiming an absent config", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-kernel-config-cli-xz-"); + const path = join(root, "bzImage"); + await writeFile(path, bzImageXzFixture()); + const result = await cli.run({ + arguments: ["inspect-kernel-build-config", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + expect(evidence.normalized_result).toMatchObject({ + payload: { codec: "xz", status: "unsupported" }, + next_probe: { operation: "external_decompression" }, + config: { status: "unsupported" }, + }); + }, +); diff --git a/tests/acceptance/localPathAdmissionCli.test.ts b/tests/acceptance/localPathAdmissionCli.test.ts index accd5cbb1a..4f012e698d 100644 --- a/tests/acceptance/localPathAdmissionCli.test.ts +++ b/tests/acceptance/localPathAdmissionCli.test.ts @@ -17,6 +17,7 @@ const fileCommands = [ "inspect-model-artifact", "inspect-elf-notes", "inspect-kernel-module", + "inspect-kernel-build-config", "inspect-byte-entropy", "inspect-pe-resources", "inspect-pe-certificates", diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 3ebfb3748f..b0eb57585b 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -253,6 +253,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_apple_ips", { path: nul }], ["inspect_devicetree", { path: nul }], ["inspect_kernel_module", { path: nul }], + ["inspect_kernel_build_config", { path: nul }], ["inspect_pe_load_config", { path: nul }], ["inspect_binary_hardening", { path: nul }], ["inspect_python_pyc", { path: nul }], diff --git a/tests/boundary/filesystem/kernelBuildConfig.test.ts b/tests/boundary/filesystem/kernelBuildConfig.test.ts new file mode 100644 index 0000000000..4a5a35528e --- /dev/null +++ b/tests/boundary/filesystem/kernelBuildConfig.test.ts @@ -0,0 +1,91 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createKernelBuildConfigService } from "../../../src/composition/binaryDiagnostics.js"; +import { + kernelBuildConfigFixture, + plainIkcfgStringFixture, +} from "../../../src/native/elf/KernelBuildConfig.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("kernel build config stable filesystem boundary", () => { + it("reports non-file selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-kernel-config-files-"); + const path = join(root, "vmlinux"); + const alias = join(root, "alias"); + await writeFile(path, kernelBuildConfigFixture()); + await symlink(path, alias); + const service = createKernelBuildConfigService(); + try { + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [join(root, "missing"), "invalid_value"], + [join(path, "child"), "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: "inspect_kernel_build_config", + 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-kernel-config-budget-"); + const path = join(root, "vmlinux"); + await writeFile(path, kernelBuildConfigFixture()); + const service = createKernelBuildConfigService(); + 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 tooMany = await service.inspect({ path, max_entries: 1 }); + if (tooMany.ok) throw new Error("Expected entry budget failure"); + expect(tooMany.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + remediationAction: expect.stringContaining("max_entries"), + }); + const inflated = await service.inspect({ path, max_config_bytes: 64 }); + if (inflated.ok) throw new Error("Expected config budget failure"); + expect(inflated.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + remediationAction: expect.stringContaining( + "Raise max_config_bytes above 64", + ), + }); + 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("reports an ordinary IKCFG_ST string as no config with file Evidence", async () => { + const root = await createTestTempDirectory("rea-kernel-config-plain-"); + const path = join(root, "vmlinux"); + await writeFile(path, plainIkcfgStringFixture()); + const service = createKernelBuildConfigService(); + try { + const result = await service.inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.subject?.format).toBe("elf"); + expect(result.value.normalized_result).toMatchObject({ + banner: { status: "present", version: "6.6.0" }, + config: { status: "absent", entries: [] }, + coverage: { status: "complete" }, + }); + } finally { + await service.close(); + } + }); +}); diff --git a/tests/boundary/mcp/kernelBuildConfigMcp.test.ts b/tests/boundary/mcp/kernelBuildConfigMcp.test.ts new file mode 100644 index 0000000000..05144c982b --- /dev/null +++ b/tests/boundary/mcp/kernelBuildConfigMcp.test.ts @@ -0,0 +1,102 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { + bzImageGzipFixture, + truncatedKernelConfigFixture, +} from "../../../src/native/elf/KernelBuildConfig.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const contract = toolContract("inspect_kernel_build_config"); + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error( + "Kernel build config inspection must not start a deep provider", + ); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_kernel_build_config and reads a bzImage gzip payload", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_kernel_build_config", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-kernel-config-mcp-"); + const path = join(root, "bzImage"); + await writeFile(path, bzImageGzipFixture()); + const response = await client.callTool({ + name: "inspect_kernel_build_config", + arguments: { path, name: "CONFIG_MODULE_SIG" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = contract.outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + payload: { container: "bzimage", codec: "gzip", status: "inflated" }, + banner: { status: "present", version: "6.6.0" }, + config: { + status: "present", + matched_count: 1, + entries: [{ name: "CONFIG_MODULE_SIG", kind: "m" }], + }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_kernel_build_config", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_kernel_build_config", + arguments: { path: "vmlinux" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_kernel_build_config" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest("reports a truncated IKCFG member as incomplete", async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-kernel-config-trunc-mcp-"); + const path = join(root, "vmlinux"); + await writeFile(path, truncatedKernelConfigFixture()); + const response = await client.callTool({ + name: "inspect_kernel_build_config", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = contract.outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + config: { status: "incomplete", reason: "config_gzip_truncated" }, + coverage: { status: "incomplete" }, + }); + } finally { + await session.close(); + } +}); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 866ab5af1f..8ff58ad423 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -113,6 +113,7 @@ import { Il2cppMetadataProvider } from "../../src/il2cpp/Il2cppMetadataProvider. import { ElfNotesProvider } from "../../src/native/elf/ElfNotesProvider.js"; import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js"; import { KernelModuleProvider } from "../../src/native/elf/KernelModuleProvider.js"; +import { KernelBuildConfigProvider } from "../../src/native/elf/KernelBuildConfigProvider.js"; import { PeCertificatesProvider } from "../../src/native/pe/PeCertificatesProvider.js"; import { PeImportsProvider } from "../../src/native/pe/PeImportsProvider.js"; import { WindowsShortcutProvider } from "../../src/native/windows/WindowsShortcutProvider.js"; @@ -121,6 +122,7 @@ import { BeamProvider } from "../../src/erlang/BeamProvider.js"; import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js"; import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js"; import { inspectKernelModuleInputSchema } from "../../src/domain/native/kernelModule.js"; +import { inspectKernelBuildConfigInputSchema } from "../../src/domain/native/kernelBuildConfig.js"; import { inspectPeCertificatesInputSchema } from "../../src/domain/native/peCertificates.js"; import { inspectPeImportsInputSchema } from "../../src/domain/native/peImports.js"; import { inspectWindowsShortcutInputSchema } from "../../src/domain/native/windowsShortcut.js"; @@ -1002,6 +1004,7 @@ const stableArtifactProviders = [ ["ELF notes", () => new ElfNotesProvider()], ["Apple IPS", () => new AppleIpsProvider()], ["kernel module", () => new KernelModuleProvider()], + ["kernel build config", () => new KernelBuildConfigProvider()], ["PE certificates", () => new PeCertificatesProvider()], ["PE load configuration", () => new PeLoadConfigProvider()], ["binary hardening", () => new BinaryHardeningProvider()], @@ -1042,6 +1045,11 @@ const inspectFileFailure = ( inspectKernelModuleInputSchema.parse({ path }), options, ); + if (provider instanceof KernelBuildConfigProvider) + return provider.inspect( + inspectKernelBuildConfigInputSchema.parse({ path }), + options, + ); if (provider instanceof PeCertificatesProvider) return provider.inspect( inspectPeCertificatesInputSchema.parse({ path }), @@ -1114,6 +1122,7 @@ it.each( expectedTag ?? (provider instanceof ElfNotesProvider || provider instanceof KernelModuleProvider || + provider instanceof KernelBuildConfigProvider || provider instanceof PeCertificatesProvider || provider instanceof PeResourcesProvider ? "ArtifactOperationError"