feat(elf): report a Linux kernel image's version banner and embedded build config (#9573)

* feat(elf): report a Linux kernel image's version banner and embedded build config

inspect_kernel_build_config and inspect-kernel-build-config read one
local vmlinux, raw arm64 Image, gzip bzImage or zImage, or configs.ko
and report its Linux version banner and the IKCONFIG .config embedded
between IKCFG_ST and IKCFG_ED, without executing it. A config is present
only when a gzip member ends exactly at IKCFG_ED, passes its CRC-32 and
ISIZE checks and parses as .config text; framing that fails is reported
malformed, never absent. bzImage gzip payloads are inflated within a
budget and other codecs are named as unsupported, using codec detection
now shared with the kernel module parser. The config search, inflate
sizes and option counts are bounded, scans honor cancellation, and
target-derived text is marked untrusted.

* fix(elf): reject a gzip member whose header checksum fails

When FHCRC is set, compare the low 16 bits of the header CRC-32 before
inflating, so a damaged header no longer yields a present config or an
inflated bzImage payload.

* fix(elf): detect the bzImage codec inside the declared payload

Codec detection now reads the same payload_offset/payload_length slice that
is inflated, so bytes past a short payload cannot name its codec.

* fix(elf): report a framed config that does not decode as malformed

A damaged or cut-off deflate stream between IKCFG_ST and IKCFG_ED was
skipped and could end as an absent config with complete coverage. It is
now malformed with config_gzip_invalid, and a later valid config still wins.

* fix(elf): scan config lines in place and accept long string values

Config text is read one line at a time instead of split into an array, and
a quoted value is validated before it is decoded once. The 4096-character
string cap is removed: Kconfig has no such limit and max_config_bytes
already bounds every line, so a long CONFIG_EXTRA_FIRMWARE list is no
longer reported as malformed.

* fix(elf): let a live cancellation reach the kernel config parser

The parser ran synchronously, so an abort raised during parsing could not
change the signal before it returned. Inflation now runs on the zlib thread
pool and each scan yields to the event loop once per MiB, so the existing
cancellation checks observe a real abort.

* refactor(elf): carry the refused kernel config budget as a typed fact

Budget failures are now KernelBuildConfigBudgetFailure with a knob field,
and the provider builds its recovery advice from that field instead of
searching the diagnostic message for an option name.

* docs(elf): move the kernel build config guide under docs/guides/native

The documentation guide assigns analyst workflows to docs/guides/<domain>/.
Move the guide there, place its navigation and directory entries in the
Native binaries group, and update links from the docs index, CLI guide,
spec and authored skill. The public /kernel-build-config route is unchanged.

* fix(elf): yield while checking one long config line

A config made of one long quoted value never reached the per-line yield, so
the control-byte check, value validation and SHA-256 ran in one synchronous
block. Each now works one MiB window per event-loop turn, and the line
patterns match only a bounded name prefix instead of the whole value.

* fix(elf): return paged config entries that do not pin the decoded text

Entry names and values were V8 slices of the whole decoded config, so a
one-entry page kept the full text alive. Entries in the returned page are
now standalone copies, so retained memory follows the page size.

* fix(elf): yield while kernel marker searches find nothing

IKCFG_ST, IKCFG_ED and banner searches used one Buffer.indexOf over the
rest of the image, so a large image with no marker never yielded. They now
search overlapping one MiB windows and yield after each window without a
match.

* fix(elf): preserve long kernel config names

* fix(elf): frame kernel configs by gzip member boundaries

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-09 21:41:56 +08:00
1 parent 7b9b6cc78b
commit f21fda0fc0
41 files changed
+3792 -14

No files matched your search

@@ -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,
@@ -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
+5
View File
@@ -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",
+1
View File
@@ -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)
+16
View File
@@ -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:
+151
View File
@@ -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.
+1 -1
View File
@@ -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) |
+2
View File
@@ -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))
@@ -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.
@@ -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.
@@ -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`).
+1
View File
@@ -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"
)
@@ -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<void>;
inspect(
input: InspectKernelBuildConfigInput,
options?: ExecutionOptions,
): Promise<Result<KernelBuildConfig, AnalysisError>>;
}
@@ -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<KernelBuildConfig> =>
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<KernelBuildConfig> => {
const malformed = await reportOf(nonConfigGzipFixture());
return {
...malformed,
coverage: { ...malformed.coverage, status: "complete" },
};
},
],
[
"an incomplete config carrying a malformed reason",
async (): Promise<KernelBuildConfig> => {
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);
});
@@ -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<void> {
return this.provider.close?.() ?? Promise.resolve();
}
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
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)
);
};
+60
View File
@@ -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",
+1
View File
@@ -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",
+5
View File
@@ -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 = (
@@ -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"]),
+2
View File
@@ -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()),
@@ -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"),
+1
View File
@@ -296,6 +296,7 @@ const TOOL_EFFECT_AUDIT: Readonly<Record<string, EffectAudit>> = {
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,
+1
View File
@@ -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"
+7
View File
@@ -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",
@@ -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,
});
});
+229
View File
@@ -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<typeof kernelBuildConfigSchema>;
export type InspectKernelBuildConfigInput = z.infer<
typeof inspectKernelBuildConfigInputSchema
>;
export type KernelBuildConfigEntry =
KernelBuildConfig["config"]["entries"][number];
+204
View File
@@ -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"),
),
);
+721
View File
@@ -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<Omit<InspectKernelBuildConfigInput, "path">> = {},
) =>
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<unknown> => {
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<number>();
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");
});
+990
View File
@@ -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<Report["config"]["reason"]>;
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<void> => {
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<number> => {
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<string> => {
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<Inflated> =>
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<GzipMember> => {
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<string | null> => {
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<string | null> => {
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<boolean> => {
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<readonly Entry[] | null> => {
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 <digit>...`
* 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<Report["banner"]> => {
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<Report["config"], "marker" | "gzip" | "end_marker"> => ({
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<Report> => {
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<ReturnType<typeof readBzImage>, { kind: "none" }>,
signal: AbortSignal | undefined,
): Promise<Report> => {
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<Report> => {
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,
);
};
+244
View File
@@ -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<void> {
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,
);
};
@@ -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();
});
+44
View File
@@ -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 = <C extends KernelCompressionCodec>(
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;
};
+4 -13
View File
@@ -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<ImageSection, { format: "elf" }>;
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 => [
+9
View File
@@ -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,
@@ -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),
);
};
@@ -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" },
});
},
);
@@ -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",
@@ -253,6 +253,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
["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 }],
@@ -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();
}
});
});
@@ -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();
}
});
@@ -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"