feat(go): inspect embedded binary build metadata (#1363)
* feat(go): inspect embedded binary build metadata * fix(go): preserve byte metadata and validate container mappings * fix(go): support moduleless legacy build metadata Recognize optional module string headers in declared zero-filled memory while rejecting ambiguous and unmapped pointers. Preserve the original module-pointer field as the zero-byte source anchor. Classify initial symlink-loop and overlong-path failures as invalid input, with CLI and MCP regressions. * fix(go): reject undersized PE optional headers * fix(native): stabilize ELF memory checks and clean up exec wrappers Use existing parent/group/run-token ownership for caller-selected Python launchers, which can exec through a shell or prlimit. Add cancellation regressions for the valid wrapper and wrong-parent/token cases. Replace the timing-dependent 30,000-section memory workload with a valid 32 MiB padded ELF under a 64 MiB address-space limit. Check the same image successfully through CLI and MCP before requiring a typed memory failure, preserving the production timeout and all error assertions. Validation: npm run check and check:fast passed; 61 focused tests passed including Go CLI/MCP and session snapshots. Real decoder probes with pinned packages passed on Python 3.12.3 and 3.12.14. The full local ELF verifier is blocked by this container's procps/process-ownership boundary; the exact pushed head requires GitHub's real-binary-layout lane. * test: repair Electron fixtures and stabilize catalog startup checks Declare Electron imports in option, preload override, and path-resolution fixtures after upstream began requiring proven API provenance. Retain all option and preload assertions and adjust exact source ranges for the added import line. Use the existing bounded acceptance timeout for the cold catalog lifecycle test instead of a separate 15-second ceiling. This retains the coverage allowance and all catalog, error, and cleanup assertions without retries. Validation: npm run check, final check:fast, formatting and diff checks passed. Source path regression and Go boundary/MCP/service checks passed. All 20 Go CLI cases and both supported-host runtime cases passed on rerun; two macOS-only cases skipped on Linux. A prior contended local run timed out once in a Go CLI diagnostic; that case passed on rerun. Electron process-boundary verification is blocked locally by procps namespace inspection and requires fresh CI. * test(mcp): isolate nested schema references between tool contracts Clear Ajv's non-meta schema registry after each root, including compilation failures. Disabling root registration alone leaves nested IDs available to later tools and can let a dangling reference pass the catalog gate. Add regressions for leakage after successful and failed compilation while retaining shared meta-schema validators. The regression failed before the fix; all 11 schema cases and full npm run check pass. Reviewed independently. * test(ci): align Go effects and source-map verification contracts Audit the Go inspector wording as static metadata inspection. Assert retained source declaration indexes in BOM reconstruction and the compiler-derived nested-map oracle. Keep a 45,000-map admission control below the 32 MiB output budget and the 50,000-map overflow case above it with the expanded representation. Validation: reproduced all four failing assertions, then passed 120 focused tests and the final admission suite. npm run check and real Chrome 154 / esbuild 0.25.10 source-map verification passed, including all four nested-map scenarios and verified process lineage. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
bd74c7af2b
commit
a1b5d51a8d
|
|
@ -77,6 +77,12 @@ explicit path or endpoint and do not need it.
|
|||
argument strings and mutability as inferred candidates; this performs no chain
|
||||
lookup or target execution. See the
|
||||
[EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md).
|
||||
- Embedded Go compiler, module or build-setting metadata: `inspect_go_binary`
|
||||
with the explicit local file. This built-in target-free inspection requires no
|
||||
Go installation or disassembler. Preserve raw module bytes and source ranges;
|
||||
absent metadata does not prove a binary is not Go, and unparsed text records
|
||||
remain explicit. See the
|
||||
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md).
|
||||
- Explicit Linux ELF file for offline layout, symbols, relocations or static
|
||||
mitigation evidence: `inspect_binary_layout`. This target-free operation uses
|
||||
caller-supplied pwntools without opening a disassembler database. Preserve its
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
# Native, managed, and packaged artifacts
|
||||
|
||||
## Go build metadata
|
||||
|
||||
Use `inspect_go_binary` for an explicit local ELF, PE or thin Mach-O when the
|
||||
question concerns the embedded compiler version, module/dependency versions,
|
||||
local replacements or build settings. No `open_binary`, Go installation or
|
||||
target execution is required. The CLI equivalent is `inspect-go-binary PATH`.
|
||||
Preserve artifact SHA-256, original file ranges, framed module bytes and every
|
||||
unparsed module-text line. A missing record is an unknown, not proof of a
|
||||
non-Go binary; observed metadata does not authenticate its claims. Function,
|
||||
source and type recovery remain a separate native-analysis question.
|
||||
|
||||
## Recorded Linux crashes
|
||||
|
||||
Use `inspect_recorded_crash` with an explicit Linux x86-64 ELF core path to
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
name: Real Go binary metadata
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/real-go-binary.yml"
|
||||
- "src/go/**"
|
||||
- "src/domain/go/**"
|
||||
- "src/domain/dosMz.ts"
|
||||
- "src/application/go/**"
|
||||
- "src/contracts/go/**"
|
||||
- "src/composition/go.ts"
|
||||
- "src/cli/goCommands.ts"
|
||||
- "src/server/registerGoTools.ts"
|
||||
- "src/server/createServer.ts"
|
||||
- "src/cli.ts"
|
||||
- "src/cliCommandNames.ts"
|
||||
- "src/contracts/toolContracts.ts"
|
||||
- "src/contracts/toolEffects.ts"
|
||||
- "src/artifacts/readStableArtifact*.ts"
|
||||
- "src/artifacts/ArtifactReader.ts"
|
||||
- "src/process/PrivateRuntimeRoot.ts"
|
||||
- "scripts/verify-go-binary.mjs"
|
||||
- "scripts/lib/mcp-verifier-results.mjs"
|
||||
- "scripts/rea.mjs"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: real-go-binary-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=768
|
||||
HUSKY: "0"
|
||||
|
||||
jobs:
|
||||
real-go-binary:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
- run: npm ci
|
||||
- name: Verify compiler-produced ELF, PE and Mach-O through public CLI and MCP
|
||||
run: npm run verify:go:binary
|
||||
|
|
@ -154,6 +154,7 @@ Additional tools and host support depend on the target:
|
|||
| ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) |
|
||||
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
|
||||
| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default defineConfig({
|
|||
text: "Investigation guides",
|
||||
items: [
|
||||
{ text: "Native binaries", link: "/native-investigation" },
|
||||
{ text: "Go build metadata", link: "/go-binaries" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
link: "/javascript-artifact-reconstruction",
|
||||
|
|
|
|||
11
docs/cli.md
11
docs/cli.md
|
|
@ -39,6 +39,17 @@ views, feature traces and comparisons that take the same saved Evidence.
|
|||
|
||||
## Native analysis
|
||||
|
||||
Inspect embedded Go build metadata without installing Go or configuring a
|
||||
disassembler:
|
||||
|
||||
```bash
|
||||
rea inspect-go-binary /absolute/path/to/program --json
|
||||
```
|
||||
|
||||
The shared CLI/MCP workflow reports compiler version, modules, replacements,
|
||||
build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md)
|
||||
for supported containers and absent or malformed metadata.
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), or the
|
||||
[IDA adapter](ida-provider.md), before analyzing a native target. Substitute
|
||||
your target, search text and function name or address in these examples:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
# Go binary build metadata
|
||||
|
||||
`inspect_go_binary` / `inspect-go-binary` answers which Go compiler version,
|
||||
modules, replacements and build settings are embedded in one selected local
|
||||
executable or library. It reads the file without running it and requires no
|
||||
Go installation, active binary target, Hopper, Ghidra or IDA.
|
||||
|
||||
```bash
|
||||
rea inspect-go-binary /absolute/path/to/application --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_go_binary",
|
||||
"arguments": { "path": "/artifacts/application" }
|
||||
}
|
||||
```
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, byte length,
|
||||
container format, architecture, word size and byte order. A recognized
|
||||
`build_info` record contains:
|
||||
|
||||
- `go_version`, preserved as embedded UTF-8 text rather than forced into a
|
||||
semantic version, or `null` when the embedded bytes are not UTF-8;
|
||||
- `go_version_bytes_base64`, preserving the exact compiler-string bytes;
|
||||
- exact file offsets and byte lengths for the header, compiler string and module
|
||||
string;
|
||||
- `module_text`, the decoded module-information body, or `null` when the complete
|
||||
body cannot be represented as UTF-8;
|
||||
- `module_bytes_base64`, the original module-string bytes including Go framing;
|
||||
- `module.path`, the embedded package path;
|
||||
- `module.main`, the main module's path, version, checksum and optional
|
||||
replacement;
|
||||
- `module.dependencies`, with the same fields and their original order;
|
||||
- `module.settings`, retaining ordered key/value entries and duplicate keys;
|
||||
- `module.unparsed_lines`, `module.unparsed_line_bytes_base64` and
|
||||
`module.complete`, so unfamiliar, malformed, conflicting or undecodable records
|
||||
do not disappear behind a complete result.
|
||||
|
||||
Missing checksums are `null`; empty fields and development/local replacement
|
||||
versions remain unchanged. Build settings such as `GOOS`, `GOARCH` or `vcs.revision`
|
||||
are reported when present. Their absence does not establish a build choice.
|
||||
Go strings may contain arbitrary bytes. Non-UTF-8 records remain available as
|
||||
base64 while other decodable module records are still reported; the reader does
|
||||
not insert replacement characters or reject the entire artifact for those
|
||||
strings.
|
||||
|
||||
## Supported boundary
|
||||
|
||||
The built-in TypeScript reader supports ELF, PE and thin Mach-O images with Go's
|
||||
inline or historical pointer build-info encoding. Source ranges refer to the
|
||||
original stable file snapshot, including pointer-backed strings. It does not
|
||||
scan arbitrary byte strings as compiler metadata or interpret a fat Mach-O
|
||||
container as one selected architecture.
|
||||
|
||||
Historical moduleless Go binaries can store their empty module-string header in
|
||||
declared zero-initialized virtual memory rather than file-backed bytes. The reader
|
||||
recognizes this only when the complete optional module header lies in that memory
|
||||
without a conflicting file mapping. Its module contents are empty and
|
||||
`module_location` has zero bytes, anchored at the build-info field containing the
|
||||
module-header pointer. Compiler strings and other unmapped pointers remain strict
|
||||
file-backed reads.
|
||||
|
||||
`build_info: null` means no recognized record was found in the inspected image.
|
||||
It does not prove that the binary is not Go. Stripping, old builds, deliberate
|
||||
removal and obfuscation can limit metadata. A malformed supported container or
|
||||
recognized build-info record returns an input-format failure. Unsupported
|
||||
containers receive a separate unsupported-target failure.
|
||||
|
||||
Symlinks and nonregular files are rejected. The shared stable reader detects
|
||||
replacement and observed in-place changes during acquisition. Missing files,
|
||||
host permission denial, changed artifacts and cancellation retain distinct
|
||||
errors. The selected file is never modified; this inspection creates no
|
||||
temporary files, starts no subprocesses and accesses no network endpoint.
|
||||
|
||||
Resource guards bound the selected file to 256 MiB, aggregate embedded strings
|
||||
to 1 MiB, structural table decoding to 16 MiB and the complete normalized metadata
|
||||
report to 8 MiB. Oversized results fail without partial success. MCP also applies
|
||||
its [response transport budget](mcp-contracts.md), which counts both text and
|
||||
structured content; a report within the metadata limit can still require export
|
||||
from retained Evidence. These limits are not operating-system memory or CPU
|
||||
isolation.
|
||||
|
||||
Embedded metadata is evidence of file content. It does not authenticate a
|
||||
compiler, dependency, commit or supply chain, and it does not establish runtime
|
||||
behavior. Function names, source mappings, types and decompiled code are outside
|
||||
this tool's coverage. Use the [native workflow](native-investigation.md) when
|
||||
those questions require a disassembler.
|
||||
|
||||
## Real producer verification
|
||||
|
||||
`npm run verify:go:binary` exercises compiler-built fixtures through the public
|
||||
CLI and MCP, comparing their metadata with Go's standard `debug/buildinfo`
|
||||
reader. The verification lane requires Go; normal inspection does not.
|
||||
|
|
@ -251,6 +251,7 @@
|
|||
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs",
|
||||
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
|
||||
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
|
||||
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const toolFamilyCatalog = (sources) => {
|
|||
contracts: [
|
||||
...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
|
||||
...sources.evmContracts.EVM_TOOL_CONTRACTS,
|
||||
...sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
|
|
@ -188,6 +189,10 @@ export const providerCatalog = (sources) => {
|
|||
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
|
||||
contracts: sources.evmContracts.EVM_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER,
|
||||
contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ const SOURCE_PATHS = {
|
|||
wasmProvider: "dist/wasm/WabtRelease.js",
|
||||
evmContracts: "dist/contracts/evm/evmToolContracts.js",
|
||||
evmProvider: "dist/evm/EvmoleRelease.js",
|
||||
goContracts: "dist/contracts/go/goToolContracts.js",
|
||||
goProvider: "dist/go/GoBinaryProvider.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
managedContracts: "dist/contracts/managed/managedToolContracts.js",
|
||||
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|||
const PURE_LAYERS = new Set(["domain", "contracts"]);
|
||||
const MIGRATED_PROVIDER_ROOTS = new Set([
|
||||
"evm",
|
||||
"go",
|
||||
"wasm",
|
||||
"android",
|
||||
"firmware",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,518 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { isUtf8 } from "node:buffer";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { goBinarySchema } from "../dist/domain/go/goBinary.js";
|
||||
import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
|
||||
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const compiler = process.env.REA_VERIFY_GO_COMMAND ?? "go";
|
||||
if (process.env.REA_VERIFY_GO_COMMAND !== undefined && !isAbsolute(compiler))
|
||||
throw new Error(
|
||||
"REA_VERIFY_GO_COMMAND must be an absolute Go compiler path.",
|
||||
);
|
||||
let compilerVersion = null;
|
||||
const run = createVerifierRun();
|
||||
const root = await PrivateRuntimeRoot.create({ prefix: "rea-go-verifier-" });
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
|
||||
);
|
||||
delete environment.HOPPER_TARGET_PATH;
|
||||
const goEnvironment = {
|
||||
...environment,
|
||||
CGO_ENABLED: "0",
|
||||
GOCACHEPROG: "",
|
||||
GO111MODULE: "on",
|
||||
GOENV: "off",
|
||||
GOFLAGS: "",
|
||||
GOMAXPROCS: "2",
|
||||
GOTOOLCHAIN: "local",
|
||||
GOWORK: "off",
|
||||
GOPROXY: "off",
|
||||
GOSUMDB: "off",
|
||||
GOPATH: join(root.path, "gopath"),
|
||||
GOCACHE: join(root.path, "cache"),
|
||||
GOMODCACHE: join(root.path, "modules"),
|
||||
GOTMPDIR: join(root.path, "tmp"),
|
||||
// Go's own verifier seam isolates telemetry; no global configuration is changed.
|
||||
TEST_TELEMETRY_DIR: join(root.path, "telemetry"),
|
||||
};
|
||||
const client = new Client({ name: "go-binary-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const targets = [
|
||||
["linux", "amd64", "elf", 64, "little"],
|
||||
["linux", "386", "elf", 32, "little"],
|
||||
["linux", "arm64", "elf", 64, "little"],
|
||||
["linux", "ppc64", "elf", 64, "big"],
|
||||
["linux", "mips", "elf", 32, "big"],
|
||||
["windows", "amd64", "pe", 64, "little"],
|
||||
["windows", "386", "pe", 32, "little"],
|
||||
["darwin", "amd64", "macho", 64, "little"],
|
||||
["darwin", "arm64", "macho", 64, "little"],
|
||||
];
|
||||
const verified = [];
|
||||
const omittedCoverage = [];
|
||||
const failures = [];
|
||||
try {
|
||||
await prepareFixture();
|
||||
try {
|
||||
compilerVersion = (
|
||||
await execute(compiler, ["version"], {
|
||||
cwd: root.path,
|
||||
env: goEnvironment,
|
||||
timeout: 10_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"verify:go:binary requires Go on PATH or absolute REA_VERIFY_GO_COMMAND; Go is only a fixture producer, not a REA runtime dependency.",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const host = (await go(["env", "GOHOSTOS", "GOHOSTARCH"])).stdout
|
||||
.trim()
|
||||
.split("\n");
|
||||
const oracle = join(
|
||||
root.path,
|
||||
host[0] === "windows" ? "buildinfo-oracle.exe" : "buildinfo-oracle",
|
||||
);
|
||||
await go(
|
||||
["build", "-trimpath", "-buildvcs=false", "-o", oracle, "./oracle"],
|
||||
{ GOOS: host[0], GOARCH: host[1] },
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const target of targets) {
|
||||
const [os, arch] = target;
|
||||
const path = join(
|
||||
root.path,
|
||||
`${os}-${arch}${os === "windows" ? ".exe" : ""}`,
|
||||
);
|
||||
await go(
|
||||
[
|
||||
"build",
|
||||
"-trimpath",
|
||||
"-buildvcs=false",
|
||||
"-ldflags=-s -w",
|
||||
"-o",
|
||||
path,
|
||||
".",
|
||||
],
|
||||
{ GOOS: os, GOARCH: arch },
|
||||
);
|
||||
await verify(path, target, oracle);
|
||||
if (os === "linux" && arch === "amd64") {
|
||||
const sectionless = join(root.path, "linux-amd64-sectionless");
|
||||
const bytes = Buffer.from(await readFile(path));
|
||||
bytes.fill(0, 40, 48); // ELF64 e_shoff.
|
||||
bytes.fill(0, 58, 64); // e_shentsize, e_shnum and e_shstrndx.
|
||||
await writeFile(sectionless, bytes);
|
||||
await verify(sectionless, target, oracle);
|
||||
}
|
||||
}
|
||||
if (process.platform === "win32")
|
||||
omittedCoverage.push(
|
||||
"Compiler-produced non-UTF-8 argv strings were not verified on Windows; Windows argv cannot preserve arbitrary byte arguments.",
|
||||
);
|
||||
else {
|
||||
const path = join(root.path, "linux-amd64-byte-strings");
|
||||
const expected = JSON.parse(
|
||||
(
|
||||
await execute(oracle, ["--build-byte-fixture", compiler, path], {
|
||||
cwd: root.path,
|
||||
env: { ...goEnvironment, GOOS: "linux", GOARCH: "amd64" },
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
).stdout,
|
||||
);
|
||||
await verify(path, targets[0], oracle, expected);
|
||||
}
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
} finally {
|
||||
for (const close of [
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
() => root.close(),
|
||||
]) {
|
||||
try {
|
||||
await close();
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
const verifier = await completeVerifierRun(run);
|
||||
try {
|
||||
await assert.rejects(readFile(join(root.path, "go.mod")), { code: "ENOENT" });
|
||||
if (verifier.process_lineage.status === "verified")
|
||||
assert.deepEqual(verifier.process_lineage.descendants, []);
|
||||
} catch (cause) {
|
||||
failures.push(cause);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "failed",
|
||||
compiler: compilerVersion,
|
||||
verified,
|
||||
omitted_coverage: omittedCoverage,
|
||||
verifier,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
"Real Go metadata verification failed; cleanup failures are retained.",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "passed",
|
||||
compiler: compilerVersion,
|
||||
binaries: verified.length,
|
||||
public_cases: verified.length * 2,
|
||||
verified,
|
||||
omitted_coverage: omittedCoverage,
|
||||
fixture_execution:
|
||||
"not-requested; only the Go fixture producer and test-owned buildinfo oracle/helper were executed",
|
||||
verifier,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
async function go(args, overrides = {}) {
|
||||
return execute(compiler, args, {
|
||||
cwd: root.path,
|
||||
env: { ...goEnvironment, ...overrides },
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareFixture() {
|
||||
await mkdir(join(root.path, "tmp"));
|
||||
await mkdir(join(root.path, "telemetry"));
|
||||
await writeFile(join(root.path, "telemetry", "mode"), "off\n");
|
||||
await mkdir(join(root.path, "dependency"));
|
||||
await mkdir(join(root.path, "oracle"));
|
||||
await writeFile(
|
||||
join(root.path, "go.mod"),
|
||||
"module example.test/rea-go-fixture\n\ngo 1.20\n\nrequire example.test/dependency v1.2.3\nreplace example.test/dependency => ./dependency\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "dependency", "go.mod"),
|
||||
"module example.test/dependency\n\ngo 1.20\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "dependency", "dependency.go"),
|
||||
'package dependency\nfunc Value() string { return "REA fixture" }\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "main.go"),
|
||||
'package main\nimport ("fmt"; "example.test/dependency")\nfunc main() { fmt.Println(dependency.Value()) }\n',
|
||||
);
|
||||
await writeFile(
|
||||
join(root.path, "oracle", "main.go"),
|
||||
String.raw`package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"debug/buildinfo"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func main() {
|
||||
byteFixture := len(os.Args) == 4 && os.Args[1] == "--build-byte-fixture"
|
||||
path := os.Args[1]
|
||||
if byteFixture {
|
||||
path = os.Args[3]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[2], "build", "-buildvcs=false",
|
||||
"-tags=" + string([]byte{0xff}),
|
||||
"-ldflags=-X runtime.buildVersion=custom" + string([]byte{0xff}),
|
||||
"-o", path, ".")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil { panic(err) }
|
||||
}
|
||||
info, err := buildinfo.ReadFile(path)
|
||||
if err != nil { panic(err) }
|
||||
var result interface{} = info
|
||||
if byteFixture {
|
||||
settings := make([]map[string]string, 0)
|
||||
byteSettings := make([]map[string]string, 0)
|
||||
for _, setting := range info.Settings {
|
||||
if utf8.ValidString(setting.Key) && utf8.ValidString(setting.Value) {
|
||||
settings = append(settings, map[string]string{"Key": setting.Key, "Value": setting.Value})
|
||||
} else {
|
||||
byteSettings = append(byteSettings, map[string]string{
|
||||
"KeyBytesBase64": base64.StdEncoding.EncodeToString([]byte(setting.Key)),
|
||||
"ValueBytesBase64": base64.StdEncoding.EncodeToString([]byte(setting.Value)),
|
||||
})
|
||||
}
|
||||
}
|
||||
result = map[string]interface{}{
|
||||
"GoVersion": nil,
|
||||
"GoVersionBytesBase64": base64.StdEncoding.EncodeToString([]byte(info.GoVersion)),
|
||||
"Path": info.Path, "Main": info.Main, "Deps": info.Deps,
|
||||
"Settings": settings, "ByteSettings": byteSettings,
|
||||
}
|
||||
}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil { panic(err) }
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inspect(mode, path) {
|
||||
let envelope;
|
||||
if (mode === "cli") {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-go-binary", path, "--json"],
|
||||
{ env: environment, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
envelope = JSON.parse(stdout);
|
||||
} else {
|
||||
const response = await client.callTool(
|
||||
{ name: "inspect_go_binary", arguments: { path } },
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, mcpTextValue(response));
|
||||
envelope = JSON.parse(mcpTextValue(response));
|
||||
}
|
||||
const evidence = parseEvidence(envelope);
|
||||
assert.equal(evidence.subject.local_path, path);
|
||||
const report = goBinarySchema.parse(evidence.normalized_result);
|
||||
assert.equal(evidence.subject.digest.sha256, report.artifact.sha256);
|
||||
return { evidence, report };
|
||||
}
|
||||
|
||||
async function verify(path, target, oracle, byteExpected = null) {
|
||||
const bytes = await readFile(path);
|
||||
const expected =
|
||||
byteExpected ??
|
||||
JSON.parse(
|
||||
(
|
||||
await execute(oracle, [path], {
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
).stdout,
|
||||
);
|
||||
const reports = [];
|
||||
for (const mode of ["cli", "mcp"]) {
|
||||
const { evidence, report } = await inspect(mode, path);
|
||||
assert.equal(report.format, target[2]);
|
||||
assert.equal(report.architecture, target[1]);
|
||||
assert.equal(
|
||||
evidence.subject.architecture,
|
||||
{ amd64: "x86_64", 386: "x86", arm64: "arm64", mips: "mips" }[
|
||||
target[1]
|
||||
] ?? null,
|
||||
);
|
||||
assert.equal(report.bits, target[3]);
|
||||
assert.equal(report.byte_order, target[4]);
|
||||
assert.equal(report.artifact.path, path);
|
||||
assert.equal(report.artifact.bytes, bytes.length);
|
||||
assert.equal(
|
||||
report.artifact.sha256,
|
||||
createHash("sha256").update(bytes).digest("hex"),
|
||||
);
|
||||
const info = report.build_info;
|
||||
assert.notEqual(info, null);
|
||||
assert.equal(info.go_version, expected.GoVersion);
|
||||
assert.equal(info.module.path, expected.Path);
|
||||
assert.deepEqual(
|
||||
normalizeObservedModule(info.module.main),
|
||||
normalizeModule(expected.Main),
|
||||
);
|
||||
assert.deepEqual(
|
||||
info.module.dependencies.map(normalizeObservedModule),
|
||||
(expected.Deps ?? []).map(normalizeModule),
|
||||
);
|
||||
assert.deepEqual(
|
||||
info.module.settings,
|
||||
(expected.Settings ?? []).map(({ Key, Value }) => ({
|
||||
key: Key,
|
||||
value: Value,
|
||||
})),
|
||||
);
|
||||
assert.equal(info.module.complete, byteExpected === null);
|
||||
if (byteExpected === null) {
|
||||
assert.deepEqual(info.module.unparsed_lines, []);
|
||||
assert.deepEqual(info.module.unparsed_line_bytes_base64, []);
|
||||
} else verifyByteStrings(info, expected);
|
||||
verifySource(info, bytes);
|
||||
reports.push(report);
|
||||
assert.deepEqual(
|
||||
await readFile(path),
|
||||
bytes,
|
||||
`${mode} changed source bytes`,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
reports[0],
|
||||
reports[1],
|
||||
"Public CLI and MCP projections disagree",
|
||||
);
|
||||
verified.push({
|
||||
target: `${target[0]}/${target[1]}`,
|
||||
file:
|
||||
byteExpected === null
|
||||
? path.endsWith("sectionless")
|
||||
? "sectionless"
|
||||
: "stripped"
|
||||
: "byte-valued-strings",
|
||||
go_version: expected.GoVersion,
|
||||
...(byteExpected === null
|
||||
? {}
|
||||
: { go_version_bytes_base64: expected.GoVersionBytesBase64 }),
|
||||
});
|
||||
}
|
||||
|
||||
function verifyByteStrings(info, expected) {
|
||||
const rawByte = Buffer.from([0xff]);
|
||||
const compilerBytes = Buffer.concat([Buffer.from("custom"), rawByte]);
|
||||
const ldflagsBytes = Buffer.concat([
|
||||
Buffer.from("-X runtime.buildVersion=custom"),
|
||||
rawByte,
|
||||
]);
|
||||
assert.equal(info.go_version, null);
|
||||
assert.equal(info.module_text, null);
|
||||
assert.equal(info.go_version_bytes_base64, compilerBytes.toString("base64"));
|
||||
assert.equal(info.go_version_bytes_base64, expected.GoVersionBytesBase64);
|
||||
assert.deepEqual(expected.ByteSettings, [
|
||||
{
|
||||
KeyBytesBase64: Buffer.from("-ldflags").toString("base64"),
|
||||
ValueBytesBase64: ldflagsBytes.toString("base64"),
|
||||
},
|
||||
{
|
||||
KeyBytesBase64: Buffer.from("-tags").toString("base64"),
|
||||
ValueBytesBase64: rawByte.toString("base64"),
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(info.module.unparsed_lines, [
|
||||
'build\t-ldflags="-X runtime.buildVersion=custom\\xff"',
|
||||
]);
|
||||
assert.deepEqual(info.module.unparsed_line_bytes_base64, [
|
||||
Buffer.concat([Buffer.from("build\t-tags="), rawByte]).toString("base64"),
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeModule(module) {
|
||||
const identity = ({ Path, Version, Sum }) => ({
|
||||
path: Path,
|
||||
version: Version,
|
||||
sum: Sum === "" || Sum === undefined ? null : Sum,
|
||||
});
|
||||
return {
|
||||
...identity(module),
|
||||
replacement: module.Replace == null ? null : identity(module.Replace),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObservedModule(module) {
|
||||
// debug/buildinfo JSON represents both absent and explicitly empty sums as "".
|
||||
const identity = (value) => ({
|
||||
...value,
|
||||
sum: value.sum === "" ? null : value.sum,
|
||||
});
|
||||
return {
|
||||
...identity(module),
|
||||
replacement:
|
||||
module.replacement === null ? null : identity(module.replacement),
|
||||
};
|
||||
}
|
||||
|
||||
function verifySource(info, bytes) {
|
||||
assert.deepEqual(
|
||||
bytes.subarray(info.header_offset, info.header_offset + 14),
|
||||
Buffer.from("ff20476f206275696c64696e663a", "hex"),
|
||||
);
|
||||
assert.equal(
|
||||
info.encoding,
|
||||
"inline",
|
||||
"Current compiler fixture must use inline string encoding",
|
||||
);
|
||||
const version = inlineString(bytes, info.header_offset + 32);
|
||||
const module = inlineString(bytes, version.offset + version.bytes.length);
|
||||
assert.deepEqual(info.version_location, {
|
||||
offset: version.offset,
|
||||
bytes: version.bytes.length,
|
||||
});
|
||||
assert.deepEqual(info.module_location, {
|
||||
offset: module.offset,
|
||||
bytes: module.bytes.length,
|
||||
});
|
||||
assert.equal(
|
||||
isUtf8(version.bytes) ? version.bytes.toString("utf8") : null,
|
||||
info.go_version,
|
||||
);
|
||||
assert.equal(version.bytes.toString("base64"), info.go_version_bytes_base64);
|
||||
assert.equal(module.bytes.toString("base64"), info.module_bytes_base64);
|
||||
assert.deepEqual(
|
||||
module.bytes.subarray(0, 16),
|
||||
Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
module.bytes.subarray(-16),
|
||||
Buffer.from("f932433186182072008242104116d8f2", "hex"),
|
||||
);
|
||||
const moduleBody = module.bytes.subarray(16, -16);
|
||||
assert.equal(
|
||||
isUtf8(moduleBody) ? moduleBody.toString("utf8") : null,
|
||||
info.module_text,
|
||||
);
|
||||
assert.equal(moduleBody.at(-1), 10);
|
||||
}
|
||||
|
||||
function inlineString(bytes, start) {
|
||||
let length = 0;
|
||||
let offset = start;
|
||||
let shift = 0;
|
||||
for (;;) {
|
||||
assert.ok(
|
||||
offset < bytes.length && shift < 49,
|
||||
"Fixture string length is invalid",
|
||||
);
|
||||
const byte = bytes[offset++];
|
||||
length += (byte & 127) * 2 ** shift;
|
||||
if ((byte & 128) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
assert.ok(
|
||||
length <= bytes.length - offset,
|
||||
"Fixture string contents are truncated",
|
||||
);
|
||||
return { offset, bytes: bytes.subarray(offset, offset + length) };
|
||||
}
|
||||
|
|
@ -334,6 +334,8 @@ const workflowAvailabilityFor = ({
|
|||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (name === "inspect_go_binary")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "trace_web_module_imports")
|
||||
return {
|
||||
reason: "available",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
GoBinary,
|
||||
InspectGoBinaryInput,
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Replaceable offline build-information reader with no target-execution authority. */
|
||||
export interface GoBinaryPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
inspect(
|
||||
input: InspectGoBinaryInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<GoBinary, AnalysisError>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { GoBinaryService } from "./GoBinaryService.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { GoBinary } from "../../domain/go/goBinary.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
|
||||
const identity = {
|
||||
id: "fixture-go",
|
||||
name: "Source-owned Go reader seam",
|
||||
version: "1",
|
||||
};
|
||||
const report: GoBinary = {
|
||||
artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 1024 },
|
||||
format: "elf",
|
||||
architecture: "riscv64",
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
build_info: null,
|
||||
limitations: ["Build information unavailable."],
|
||||
};
|
||||
|
||||
it("rejects invalid paths and extra caller arguments before invoking the reader", async () => {
|
||||
let calls = 0;
|
||||
const service = new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
for (const input of [
|
||||
{ path: "relative" },
|
||||
{ path: "/artifacts/\0program" },
|
||||
{ path: report.artifact.path, execute: true },
|
||||
]) {
|
||||
const result = await service.inspect(input);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
}
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves unsupported subject architectures and does not duplicate metadata", async () => {
|
||||
const service = new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(report)),
|
||||
});
|
||||
const result = await service.inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.subject).toMatchObject({
|
||||
architecture: null,
|
||||
local_path: report.artifact.path,
|
||||
digest: { sha256: report.artifact.sha256 },
|
||||
});
|
||||
expect(result.value.normalized_result).toEqual(report);
|
||||
expect(result.value.raw_result).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves byte-valued build metadata and validates compiler source lengths", async () => {
|
||||
const moduleBytes = Buffer.concat([
|
||||
Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex"),
|
||||
Buffer.from([0xff, 10]),
|
||||
Buffer.from("f932433186182072008242104116d8f2", "hex"),
|
||||
]);
|
||||
const buildInfo: NonNullable<GoBinary["build_info"]> = {
|
||||
header_offset: 64,
|
||||
encoding: "inline",
|
||||
go_version: null,
|
||||
go_version_bytes_base64: "/w==",
|
||||
module_text: null,
|
||||
module_bytes_base64: moduleBytes.toString("base64"),
|
||||
version_location: { offset: 128, bytes: 1 },
|
||||
module_location: { offset: 256, bytes: moduleBytes.length },
|
||||
module: {
|
||||
path: null,
|
||||
main: null,
|
||||
dependencies: [],
|
||||
settings: [],
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: ["/w=="],
|
||||
complete: false,
|
||||
},
|
||||
};
|
||||
const partial: GoBinary = {
|
||||
...report,
|
||||
build_info: buildInfo,
|
||||
limitations: ["Some embedded strings contain non-UTF-8 bytes."],
|
||||
};
|
||||
const result = await new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(partial)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.normalized_result).toEqual(partial);
|
||||
|
||||
const mismatched: GoBinary = {
|
||||
...partial,
|
||||
build_info: {
|
||||
...buildInfo,
|
||||
version_location: { offset: 128, bytes: 2 },
|
||||
},
|
||||
};
|
||||
const invalid = await new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(mismatched)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("refuses reader identity changes and invalid original source ranges", async () => {
|
||||
const outside: GoBinary = {
|
||||
...report,
|
||||
build_info: {
|
||||
header_offset: 1000,
|
||||
encoding: "inline",
|
||||
go_version: "go1.26.0",
|
||||
go_version_bytes_base64: Buffer.from("go1.26.0").toString("base64"),
|
||||
module_text: "",
|
||||
module_bytes_base64: "",
|
||||
version_location: { offset: 2000, bytes: 8 },
|
||||
module_location: { offset: 0, bytes: 0 },
|
||||
module: {
|
||||
path: null,
|
||||
main: null,
|
||||
dependencies: [],
|
||||
settings: [],
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: [],
|
||||
complete: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const value of [
|
||||
{ ...report, artifact: { ...report.artifact, path: "/artifacts/other" } },
|
||||
outside,
|
||||
]) {
|
||||
const result = await new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(value)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
}
|
||||
});
|
||||
|
||||
it("honors cancellation before reading and after a completed observation", async () => {
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const service = new GoBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const completed = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(completed.ok).toBe(false);
|
||||
if (!completed.ok)
|
||||
expect(completed.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
const before = await service.inspect(
|
||||
{ path: report.artifact.path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(before.ok).toBe(false);
|
||||
if (!before.ok) expect(before.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { GoBinaryPort } from "./GoBinaryPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
goBinarySchema,
|
||||
inspectGoBinaryInputSchema,
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
const OPERATION = "inspect_go_binary";
|
||||
const architectureForSubject = (architecture: string) => {
|
||||
switch (architecture) {
|
||||
case "386":
|
||||
return "x86";
|
||||
case "amd64":
|
||||
return "x86_64";
|
||||
default:
|
||||
return BINARY_ARCHITECTURES.find(
|
||||
(supported) => supported === architecture,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */
|
||||
export class GoBinaryService {
|
||||
constructor(readonly provider: GoBinaryPort) {}
|
||||
|
||||
/** Inspect one explicitly selected file without acquiring a binary session. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectGoBinaryInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = goBinarySchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Go build-information reader returned malformed metadata or changed the selected artifact identity.",
|
||||
),
|
||||
);
|
||||
const value = report.data;
|
||||
const architecture = architectureForSubject(value.architecture);
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
this.provider.identity,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...(value.build_info === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: value.build_info.header_offset,
|
||||
end: value.build_info.header_offset + 32,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { registerWasmCommands } from "./cli/wasmCommands.js";
|
|||
import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
|
||||
import { createDirectAnalysis } from "./composition/directAnalysis.js";
|
||||
import { registerEvmCommands } from "./cli/evmCommands.js";
|
||||
import { registerGoCommands } from "./cli/goCommands.js";
|
||||
import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
|
||||
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
|
||||
import { Cli } from "incur";
|
||||
|
|
@ -75,6 +76,7 @@ export const createCli = (
|
|||
registerUtilityCommands(cli, logger, environment, analysis);
|
||||
registerWasmCommands(cli, logger, environment);
|
||||
registerEvmCommands(cli, logger, environment);
|
||||
registerGoCommands(cli, logger);
|
||||
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
registerAndroidCommands(cli, logger, environment);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from "incur";
|
||||
import { resolve } from "node:path";
|
||||
import { createGoBinaryService } from "../composition/go.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Resolve caller paths and inspect static build metadata through the shared workflow. */
|
||||
export const registerGoCommands = (cli: CliInstance, logger: Logger): void => {
|
||||
const service = createGoBinaryService();
|
||||
cli.command(CLI_COMMANDS.inspectGoBinary, {
|
||||
description:
|
||||
"Inspect embedded Go compiler, modules and build settings without target execution",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local ELF, PE or thin Mach-O image"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectGoBinary, async () => {
|
||||
const result = await service.inspect(
|
||||
{ path: resolve(args.path) },
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
@ -42,6 +42,7 @@ export const CLI_COMMANDS = Object.freeze({
|
|||
inspectManagedArtifact: "inspect-managed-artifact",
|
||||
inspectRecordedCrash: "inspect-recorded-crash",
|
||||
inspectBinaryLayout: "inspect-binary-layout",
|
||||
inspectGoBinary: "inspect-go-binary",
|
||||
inspectPeResources: "inspect-pe-resources",
|
||||
inspectAnalysisView: "inspect-analysis-view",
|
||||
inspectEvidenceBundle: "inspect-evidence-bundle",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import { GoBinaryProvider } from "../go/GoBinaryProvider.js";
|
||||
|
||||
/** Compose bundled offline inspection without acquiring a toolchain or starting any process. */
|
||||
export const createGoBinaryService = (): GoBinaryService =>
|
||||
new GoBinaryService(new GoBinaryProvider());
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
goBinarySchema,
|
||||
inspectGoBinaryInputSchema,
|
||||
} from "../../domain/go/goBinary.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
|
||||
/** Static Go metadata is independent of active native-analysis providers and host toolchains. */
|
||||
export const GO_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_go_binary",
|
||||
...toolContractMetadata("inspect_go_binary"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Inspect embedded Go compiler version, main module, dependency/replacement versions and build settings in an explicit local ELF, PE or thin Mach-O image. Returns original artifact SHA-256, exact build-info source ranges, raw framed module bytes and unparsed module-text lines inline as observed Evidence. Supports inline and historical pointer encodings without installing Go, opening a disassembler, executing the target, writing temporary files or accessing the network. Missing metadata is reported as unknown, not proof that an image is not Go. Does not recover functions, source mappings, types or runtime behavior. Resource guards bound files to 256 MiB, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete inline metadata to 8 MiB; malformed, unsupported and oversized inputs receive typed failures rather than partial success.",
|
||||
inputSchema: inspectGoBinaryInputSchema,
|
||||
outputSchema: evidenceResultOf(goBinarySchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect embedded build provenance",
|
||||
input: { path: "/artifacts/application" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js";
|
||||
import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
|
||||
import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js";
|
||||
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js";
|
||||
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js";
|
||||
|
|
@ -32,6 +33,7 @@ export const TOOL_CONTRACTS = [
|
|||
...ANALYSIS_VIEW_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...EVM_TOOL_CONTRACTS,
|
||||
...GO_TOOL_CONTRACTS,
|
||||
...WASM_TOOL_CONTRACTS,
|
||||
...MANAGED_TOOL_CONTRACTS,
|
||||
...ANDROID_TOOL_CONTRACTS,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
|||
inspect_native_load_image: "states that it does not execute the target",
|
||||
resolve_native_call_targets: "states that it does not execute code",
|
||||
inspect_pe_resources: "states that it does not execute the image",
|
||||
inspect_go_binary: "reads embedded metadata without executing the target",
|
||||
inspect_binary_layout:
|
||||
"parses the binary offline with operator-configured pwntools",
|
||||
inspect_recorded_crash: "inspects a core file without launching its target",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const sessionEvidence = effects({ mutatesSession: true });
|
|||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_go_binary: evidence,
|
||||
inspect_wasm_artifact: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
|
||||
const sourceRangeSchema = z.strictObject({
|
||||
offset: z.number().int().nonnegative(),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
});
|
||||
const moduleIdentitySchema = z.strictObject({
|
||||
path: z.string(),
|
||||
version: z.string(),
|
||||
sum: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Module identities retain development versions, local replacements and missing sums verbatim. */
|
||||
export const goModuleSchema = moduleIdentitySchema.extend({
|
||||
replacement: moduleIdentitySchema.nullable(),
|
||||
});
|
||||
|
||||
/** Parsed records alongside unrecognized text lines and exact non-UTF-8 line bytes. */
|
||||
export const goModuleMetadataSchema = z.strictObject({
|
||||
path: z.string().nullable(),
|
||||
main: goModuleSchema.nullable(),
|
||||
dependencies: z.array(goModuleSchema),
|
||||
settings: z.array(z.strictObject({ key: z.string(), value: z.string() })),
|
||||
unparsed_lines: z.array(z.string()),
|
||||
unparsed_line_bytes_base64: z.array(
|
||||
z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
),
|
||||
complete: z.boolean(),
|
||||
});
|
||||
|
||||
/** One exact local artifact, independent of any active disassembler target. */
|
||||
export const inspectGoBinaryInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute filesystem path to the selected executable or library",
|
||||
),
|
||||
});
|
||||
|
||||
/** Compiler-written build information and its original file-backed source ranges. */
|
||||
export const goBuildInfoSchema = z.strictObject({
|
||||
header_offset: z.number().int().nonnegative(),
|
||||
encoding: z.enum(["inline", "pointer"]),
|
||||
go_version: z.string().nullable(),
|
||||
go_version_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
module_text: z.string().nullable(),
|
||||
module_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
version_location: sourceRangeSchema,
|
||||
module_location: sourceRangeSchema,
|
||||
module: goModuleMetadataSchema,
|
||||
});
|
||||
|
||||
/** Complete bounded build-metadata observation; absence does not identify a binary as non-Go. */
|
||||
export const goBinarySchema = z
|
||||
.strictObject({
|
||||
artifact: z.strictObject({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
bytes: z.number().int().nonnegative(),
|
||||
}),
|
||||
format: z.enum(["elf", "pe", "macho"]),
|
||||
architecture: z.string(),
|
||||
bits: z.union([z.literal(32), z.literal(64)]),
|
||||
byte_order: z.enum(["little", "big"]),
|
||||
build_info: goBuildInfoSchema.nullable(),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((report, context) => {
|
||||
const info = report.build_info;
|
||||
if (info === null) return;
|
||||
if (info.header_offset > report.artifact.bytes - 32)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["build_info", "header_offset"],
|
||||
message: "Build-info header is outside the selected artifact.",
|
||||
});
|
||||
for (const field of ["version_location", "module_location"] as const) {
|
||||
const range = info[field];
|
||||
if (
|
||||
range.offset > report.artifact.bytes ||
|
||||
range.bytes > report.artifact.bytes - range.offset
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["build_info", field],
|
||||
message: "Build-info source range is outside the selected artifact.",
|
||||
});
|
||||
}
|
||||
for (const [field, range, description] of [
|
||||
["go_version_bytes_base64", info.version_location, "compiler"],
|
||||
["module_bytes_base64", info.module_location, "module"],
|
||||
] as const) {
|
||||
const encoded = info[field];
|
||||
const padding = encoded.endsWith("==")
|
||||
? 2
|
||||
: encoded.endsWith("=")
|
||||
? 1
|
||||
: 0;
|
||||
if ((encoded.length / 4) * 3 - padding !== range.bytes)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["build_info", field],
|
||||
message: `Encoded ${description} bytes do not match their reported source length.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type GoModule = z.infer<typeof goModuleSchema>;
|
||||
export type GoModuleMetadata = z.infer<typeof goModuleMetadataSchema>;
|
||||
export type GoBinary = z.infer<typeof goBinarySchema>;
|
||||
export type InspectGoBinaryInput = z.infer<typeof inspectGoBinaryInputSchema>;
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/** Distinguish corrupt recognized binaries from containers outside parser coverage. */
|
||||
export class GoBinaryFormatFailure extends Error {
|
||||
constructor(
|
||||
readonly kind: "malformed" | "unsupported",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GoBinaryFormatFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resource boundary exceeded while decoding metadata or native container structure. */
|
||||
export class GoBinaryResourceFailure extends Error {
|
||||
constructor(
|
||||
readonly boundary: "build-info" | "structure",
|
||||
readonly maximum_bytes: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GoBinaryResourceFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/** One file-backed virtual range; addresses stay exact until mapped to file offsets. */
|
||||
export interface GoFileMapping {
|
||||
readonly address: bigint;
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Declared zero-initialized virtual memory with no corresponding file bytes. */
|
||||
export interface GoZeroFillMapping {
|
||||
readonly address: bigint;
|
||||
readonly size: bigint;
|
||||
}
|
||||
|
||||
/** Native container facts and its producer-defined build-info search area. */
|
||||
export interface GoBinaryContainer {
|
||||
readonly format: "elf" | "pe" | "macho";
|
||||
readonly architecture: string;
|
||||
readonly bits: 32 | 64;
|
||||
readonly byte_order: "little" | "big";
|
||||
readonly mappings: readonly GoFileMapping[];
|
||||
readonly zero_fills: readonly GoZeroFillMapping[];
|
||||
readonly search: GoFileMapping | null;
|
||||
}
|
||||
|
||||
/** Reader that checks every structural range before decoding numeric fields. */
|
||||
export class GoBinaryReader {
|
||||
constructor(
|
||||
readonly bytes: Buffer,
|
||||
readonly little: boolean,
|
||||
) {}
|
||||
|
||||
range(offset: number, size: number, label: string): Buffer {
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(size) ||
|
||||
offset < 0 ||
|
||||
size < 0 ||
|
||||
offset > this.bytes.length ||
|
||||
size > this.bytes.length - offset
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
`${label} range is truncated or outside the file`,
|
||||
);
|
||||
return this.bytes.subarray(offset, offset + size);
|
||||
}
|
||||
|
||||
u16(offset: number): number {
|
||||
const value = this.range(offset, 2, "16-bit field");
|
||||
return this.little ? value.readUInt16LE() : value.readUInt16BE();
|
||||
}
|
||||
|
||||
u32(offset: number): number {
|
||||
const value = this.range(offset, 4, "32-bit field");
|
||||
return this.little ? value.readUInt32LE() : value.readUInt32BE();
|
||||
}
|
||||
|
||||
word(offset: number, bits: 32 | 64): bigint {
|
||||
if (bits === 32) return BigInt(this.u32(offset));
|
||||
const value = this.range(offset, 8, "64-bit field");
|
||||
return this.little ? value.readBigUInt64LE() : value.readBigUInt64BE();
|
||||
}
|
||||
|
||||
fileRange(offset: bigint, size: bigint, label: string): GoFileMapping {
|
||||
if (
|
||||
offset > BigInt(this.bytes.length) ||
|
||||
size > BigInt(this.bytes.length) - offset
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
`${label} range is outside the file`,
|
||||
);
|
||||
const result = { address: 0n, offset: Number(offset), size: Number(size) };
|
||||
this.range(result.offset, result.size, label);
|
||||
return result;
|
||||
}
|
||||
|
||||
table(offset: bigint, count: number, width: number, label: string): number {
|
||||
const size = BigInt(count) * BigInt(width);
|
||||
if (size > 16n * 1024n * 1024n)
|
||||
throw new GoBinaryResourceFailure(
|
||||
"structure",
|
||||
16 * 1024 * 1024,
|
||||
`${label} exceeds the 16 MiB structural decoding budget`,
|
||||
);
|
||||
return this.fileRange(offset, size, label).offset;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a virtual span without losing precision or selecting an ambiguous alias. */
|
||||
export const mappedFileOffset = (
|
||||
mappings: readonly GoFileMapping[],
|
||||
address: bigint,
|
||||
size: number,
|
||||
zeroFills: readonly GoZeroFillMapping[] = [],
|
||||
): number => {
|
||||
let found: number | undefined;
|
||||
for (const mapping of mappings) {
|
||||
const delta = address - mapping.address;
|
||||
if (
|
||||
delta < 0n ||
|
||||
delta > BigInt(mapping.size) ||
|
||||
BigInt(size) > BigInt(mapping.size) - delta
|
||||
)
|
||||
continue;
|
||||
const offset = mapping.offset + Number(delta);
|
||||
if (found !== undefined && found !== offset)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Virtual address has ambiguous file mappings",
|
||||
);
|
||||
found = offset;
|
||||
}
|
||||
if (found === undefined)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Virtual address or string range is not file mapped",
|
||||
);
|
||||
for (const zeroFill of zeroFills) {
|
||||
if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Virtual address has ambiguous file and zero-fill mappings",
|
||||
);
|
||||
}
|
||||
const end = address + BigInt(size);
|
||||
for (const mapping of mappings) {
|
||||
const start = address > mapping.address ? address : mapping.address;
|
||||
const mappingEnd = mapping.address + BigInt(mapping.size);
|
||||
const overlapEnd = end < mappingEnd ? end : mappingEnd;
|
||||
if (start >= overlapEnd) continue;
|
||||
if (
|
||||
BigInt(found) + start - address !==
|
||||
BigInt(mapping.offset) + start - mapping.address
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Virtual address has ambiguous file mappings",
|
||||
);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Recognize a wholly zero-filled span while refusing even partial file aliases. */
|
||||
export const isZeroFilledSpan = (
|
||||
mappings: readonly GoFileMapping[],
|
||||
zeroFills: readonly GoZeroFillMapping[],
|
||||
address: bigint,
|
||||
size: number,
|
||||
): boolean => {
|
||||
const bytes = BigInt(size);
|
||||
if (
|
||||
!zeroFills.some(
|
||||
(mapping) =>
|
||||
address >= mapping.address &&
|
||||
address + bytes <= mapping.address + mapping.size,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
for (const mapping of mappings) {
|
||||
if (spansOverlap(address, bytes, mapping.address, BigInt(mapping.size)))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Virtual address has ambiguous file and zero-fill mappings",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const spansOverlap = (
|
||||
address: bigint,
|
||||
size: bigint,
|
||||
otherAddress: bigint,
|
||||
otherSize: bigint,
|
||||
): boolean =>
|
||||
size > 0n &&
|
||||
otherSize > 0n &&
|
||||
address < otherAddress + otherSize &&
|
||||
otherAddress < address + size;
|
||||
|
||||
/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */
|
||||
export const goUtf8 = (bytes: Buffer, label: string): string => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
`${label} contains malformed UTF-8: ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryResourceFailure,
|
||||
GoBinaryReader,
|
||||
goUtf8,
|
||||
mappedFileOffset,
|
||||
isZeroFilledSpan,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
import { readGoElfImage } from "./GoElfImage.js";
|
||||
import { readGoMachoImage } from "./GoMachoImage.js";
|
||||
import { readGoPeImage } from "./GoPeImage.js";
|
||||
|
||||
export {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryResourceFailure,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex");
|
||||
const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex");
|
||||
const MODULE_END = Buffer.from("f932433186182072008242104116d8f2", "hex");
|
||||
const MAX_BUILD_INFO_BYTES = 1024 * 1024;
|
||||
|
||||
/** Exact file span of one embedded string's contents, excluding its length/header. */
|
||||
export interface GoStringLocation {
|
||||
readonly offset: number;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/** Observed compiler string and validated module body with its original framing bytes. */
|
||||
export interface GoBuildInfo {
|
||||
readonly header_offset: number;
|
||||
readonly encoding: "inline" | "pointer";
|
||||
readonly go_version: string | null;
|
||||
readonly go_version_bytes_base64: string;
|
||||
readonly module_text: string | null;
|
||||
readonly module_bytes_base64: string;
|
||||
readonly version_location: GoStringLocation;
|
||||
readonly module_location: GoStringLocation;
|
||||
}
|
||||
|
||||
/** Native image identity and optional Go build information; absence is not a language verdict. */
|
||||
export interface GoBinaryImage {
|
||||
readonly format: "elf" | "pe" | "macho";
|
||||
readonly architecture: string;
|
||||
readonly bits: 32 | 64;
|
||||
readonly byte_order: "little" | "big";
|
||||
readonly build_info: GoBuildInfo | null;
|
||||
}
|
||||
|
||||
/** Inspect bounded executable bytes using native mappings, without loading or executing code. */
|
||||
export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
|
||||
const container = readContainer(bytes);
|
||||
const { format, architecture, bits, byte_order } = container;
|
||||
const search = container.search;
|
||||
const header = findHeader(bytes, search);
|
||||
if (header === null || search === null)
|
||||
return { format, architecture, bits, byte_order, build_info: null };
|
||||
const verifyMapping = (size: number): void => {
|
||||
const address = search.address + BigInt(header - search.offset);
|
||||
if (
|
||||
mappedFileOffset(
|
||||
container.mappings,
|
||||
address,
|
||||
size,
|
||||
container.zero_fills,
|
||||
) !== header
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info bytes disagree with their virtual mapping",
|
||||
);
|
||||
};
|
||||
verifyMapping(32);
|
||||
const reader = new GoBinaryReader(bytes, byte_order === "little");
|
||||
const ptrSize = bytes[header + 14];
|
||||
const flags = bytes[header + 15] ?? 0;
|
||||
if ((flags & ~3) !== 0)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Go build-info flags contain an unsupported format bit",
|
||||
);
|
||||
if ((flags & 2) === 0 && (flags & 1) !== (byte_order === "big" ? 1 : 0))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info byte order disagrees with the image",
|
||||
);
|
||||
const encoding = (flags & 2) !== 0 ? "inline" : "pointer";
|
||||
const readInline = (offset: number, remaining: number) =>
|
||||
readInlineString(reader, offset, remaining);
|
||||
const sourceEnd = search.offset + search.size;
|
||||
let version: LocatedString;
|
||||
let module: LocatedString;
|
||||
if (encoding === "inline") {
|
||||
version = readInline(header + 32, sourceEnd - header - 32);
|
||||
const next = version.location.offset + version.location.bytes;
|
||||
module = readInline(next, sourceEnd - next);
|
||||
verifyMapping(module.location.offset + module.location.bytes - header);
|
||||
} else {
|
||||
if (ptrSize !== 4 && ptrSize !== 8)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info pointer width must be 4 or 8",
|
||||
);
|
||||
if (ptrSize * 8 !== bits)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info pointer width disagrees with the image",
|
||||
);
|
||||
const pointerBits = ptrSize === 8 ? 64 : 32;
|
||||
version = readPointerString(
|
||||
reader,
|
||||
container,
|
||||
reader.word(header + 16, pointerBits),
|
||||
);
|
||||
const moduleField = header + 16 + ptrSize;
|
||||
module = readOptionalModuleString(
|
||||
reader,
|
||||
container,
|
||||
reader.word(moduleField, pointerBits),
|
||||
moduleField,
|
||||
);
|
||||
}
|
||||
if (version.location.bytes + module.location.bytes > MAX_BUILD_INFO_BYTES)
|
||||
throw new GoBinaryResourceFailure(
|
||||
"build-info",
|
||||
MAX_BUILD_INFO_BYTES,
|
||||
"Go build-info strings exceed the aggregate 1 MiB limit",
|
||||
);
|
||||
if (version.bytes.length === 0)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info compiler version is empty",
|
||||
);
|
||||
const goVersion = observedText(version.bytes);
|
||||
let moduleText: string | null = "";
|
||||
if (module.bytes.length !== 0) {
|
||||
if (
|
||||
module.bytes.length < 33 ||
|
||||
!module.bytes.subarray(0, 16).equals(MODULE_START) ||
|
||||
!module.bytes.subarray(-16).equals(MODULE_END) ||
|
||||
module.bytes[module.bytes.length - 17] !== 10
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go module string framing is invalid",
|
||||
);
|
||||
moduleText = observedText(module.bytes.subarray(16, -16));
|
||||
}
|
||||
return {
|
||||
format,
|
||||
architecture,
|
||||
bits,
|
||||
byte_order,
|
||||
build_info: {
|
||||
header_offset: header,
|
||||
encoding,
|
||||
go_version: goVersion,
|
||||
go_version_bytes_base64: version.bytes.toString("base64"),
|
||||
module_text: moduleText,
|
||||
module_bytes_base64: module.bytes.toString("base64"),
|
||||
version_location: version.location,
|
||||
module_location: module.location,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const observedText = (bytes: Buffer): string | null => {
|
||||
try {
|
||||
return goUtf8(bytes, "Go build string");
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure) return null;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
interface LocatedString {
|
||||
readonly bytes: Buffer;
|
||||
readonly location: GoStringLocation;
|
||||
}
|
||||
|
||||
const readContainer = (bytes: Buffer): GoBinaryContainer => {
|
||||
if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
|
||||
return readGoElfImage(bytes);
|
||||
if (bytes.subarray(0, 2).equals(Buffer.from("MZ")))
|
||||
return readGoPeImage(bytes);
|
||||
if (bytes.length >= 4) {
|
||||
const magic = bytes.readUInt32BE();
|
||||
if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Universal/fat Mach-O containers require selecting a thin slice",
|
||||
);
|
||||
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
|
||||
return readGoMachoImage(bytes);
|
||||
}
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Only ELF, PE, and thin Mach-O native images are supported",
|
||||
);
|
||||
};
|
||||
|
||||
const findHeader = (
|
||||
bytes: Buffer,
|
||||
search: GoFileMapping | null,
|
||||
): number | null => {
|
||||
if (search === null || search.size === 0) return null;
|
||||
const end = search.offset + search.size;
|
||||
const selected = bytes.subarray(search.offset, end);
|
||||
let offset = Number((16n - (search.address % 16n)) % 16n);
|
||||
while (offset < selected.length) {
|
||||
const match = selected.indexOf(MAGIC, offset);
|
||||
if (match < 0) return null;
|
||||
const candidate = search.offset + match;
|
||||
const address = search.address + BigInt(candidate - search.offset);
|
||||
if (address % 16n === 0n) {
|
||||
if (end - candidate < 32)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info header is truncated",
|
||||
);
|
||||
return candidate;
|
||||
}
|
||||
offset = match + 1;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readInlineString = (
|
||||
reader: GoBinaryReader,
|
||||
offset: number,
|
||||
remaining: number,
|
||||
): LocatedString => {
|
||||
let length = 0n;
|
||||
for (let index = 0; index < 10; index++) {
|
||||
if (index >= remaining)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info varint is truncated",
|
||||
);
|
||||
const byte = reader.bytes[offset + index] ?? 0;
|
||||
if (index === 9 && byte > 1)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info varint overflows 64 bits",
|
||||
);
|
||||
length |= BigInt(byte & 127) << BigInt(index * 7);
|
||||
if ((byte & 128) === 0) {
|
||||
if (length > BigInt(MAX_BUILD_INFO_BYTES))
|
||||
throw new GoBinaryResourceFailure(
|
||||
"build-info",
|
||||
MAX_BUILD_INFO_BYTES,
|
||||
"Go build-info string length exceeds the 1 MiB limit",
|
||||
);
|
||||
const size = Number(length);
|
||||
if (size > remaining - index - 1)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info string range is truncated",
|
||||
);
|
||||
const start = offset + index + 1;
|
||||
return {
|
||||
bytes: reader.range(start, size, "Go build-info string"),
|
||||
location: { offset: start, bytes: size },
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Go build-info varint is unterminated",
|
||||
);
|
||||
};
|
||||
|
||||
const readPointerString = (
|
||||
reader: GoBinaryReader,
|
||||
container: GoBinaryContainer,
|
||||
address: bigint,
|
||||
): LocatedString => {
|
||||
const { mappings, zero_fills: zeroFills, bits } = container;
|
||||
const width = bits / 8;
|
||||
const header = mappedFileOffset(mappings, address, width * 2, zeroFills);
|
||||
const valueAddress = reader.word(header, bits);
|
||||
const length = reader.word(header + width, bits);
|
||||
if (length > BigInt(MAX_BUILD_INFO_BYTES))
|
||||
throw new GoBinaryResourceFailure(
|
||||
"build-info",
|
||||
MAX_BUILD_INFO_BYTES,
|
||||
"Go build-info pointer string length exceeds the 1 MiB limit",
|
||||
);
|
||||
const size = Number(length);
|
||||
// Zero-length Go strings may carry a zero data pointer.
|
||||
const offset =
|
||||
size === 0 && valueAddress === 0n
|
||||
? header
|
||||
: mappedFileOffset(mappings, valueAddress, size, zeroFills);
|
||||
return {
|
||||
bytes: reader.range(offset, size, "Go build-info pointer string"),
|
||||
location: { offset, bytes: size },
|
||||
};
|
||||
};
|
||||
|
||||
const readOptionalModuleString = (
|
||||
reader: GoBinaryReader,
|
||||
container: GoBinaryContainer,
|
||||
address: bigint,
|
||||
pointerField: number,
|
||||
): LocatedString =>
|
||||
isZeroFilledSpan(
|
||||
container.mappings,
|
||||
container.zero_fills,
|
||||
address,
|
||||
(container.bits / 8) * 2,
|
||||
)
|
||||
? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } }
|
||||
: readPointerString(reader, container, address);
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type { GoBinaryPort } from "../application/go/GoBinaryPort.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
goBinarySchema,
|
||||
type GoBinary,
|
||||
type InspectGoBinaryInput,
|
||||
} from "../domain/go/goBinary.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
readGoBinaryImage,
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryResourceFailure,
|
||||
} from "./GoBinaryImage.js";
|
||||
import { parseGoModuleBytes, parseGoModuleText } from "./GoModuleText.js";
|
||||
|
||||
/** Bundled format reader identity; embedded compiler versions are separate observed facts. */
|
||||
export const GO_BINARY_PROVIDER_IDENTITY = {
|
||||
id: "go-build-info",
|
||||
name: "REA Go build-information reader",
|
||||
version: "1",
|
||||
} as const;
|
||||
const OPERATION = "inspect_go_binary";
|
||||
const INPUT_BYTES = 256 * 1024 * 1024;
|
||||
const OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted) return new AnalysisCancelledError(OPERATION);
|
||||
if (cause instanceof GoBinaryResourceFailure)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"memory",
|
||||
`Selected artifact ${path}: ${cause.message}`,
|
||||
{ boundary: cause.boundary, maximum_bytes: cause.maximum_bytes },
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: `Selected artifact ${path}: ${cause.message}`,
|
||||
},
|
||||
]);
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION);
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"file-size",
|
||||
cause.message,
|
||||
{ maximum_input_bytes: INPUT_BYTES },
|
||||
{ cause },
|
||||
);
|
||||
if (cause.reason === "path")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_format", message: cause.message },
|
||||
]);
|
||||
}
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
if (cause.code === "EACCES" || cause.code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (
|
||||
["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(
|
||||
String(cause.code),
|
||||
)
|
||||
)
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return new ProviderAdapterError(GO_BINARY_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
path,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const outputLimit = (path: string) =>
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"transport",
|
||||
`Complete Go metadata for ${path} exceeds the inline output budget; no partial success is returned.`,
|
||||
{ maximum_output_bytes: OUTPUT_BYTES },
|
||||
);
|
||||
|
||||
/** Stable in-process metadata inspection without subprocesses, temporary files or Go installation. */
|
||||
export class GoBinaryProvider implements GoBinaryPort {
|
||||
readonly identity = GO_BINARY_PROVIDER_IDENTITY;
|
||||
|
||||
/** Read complete compiler/module metadata while preserving original byte identity and source ranges. */
|
||||
async inspect(
|
||||
input: InspectGoBinaryInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<GoBinary, AnalysisError>> {
|
||||
try {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
INPUT_BYTES,
|
||||
options?.signal,
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
const image = readGoBinaryImage(snapshot.bytes);
|
||||
const info = image.build_info;
|
||||
if (
|
||||
info !== null &&
|
||||
((info.module_text !== null &&
|
||||
Buffer.byteLength(info.module_text) > OUTPUT_BYTES) ||
|
||||
Buffer.byteLength(info.module_bytes_base64) > OUTPUT_BYTES)
|
||||
)
|
||||
throw outputLimit(input.path);
|
||||
const module =
|
||||
info === null
|
||||
? null
|
||||
: info.module_text === null
|
||||
? parseGoModuleBytes(
|
||||
Buffer.from(info.module_bytes_base64, "base64").subarray(
|
||||
16,
|
||||
-16,
|
||||
),
|
||||
)
|
||||
: parseGoModuleText(info.module_text);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
build_info: info === null ? null : { ...info, module },
|
||||
limitations: [
|
||||
"Embedded build metadata is observed file content, not proof of compiler identity, dependency authenticity or runtime behavior.",
|
||||
"Only Go build information is inspected; function names, source mappings, type metadata and decompiled code are not recovered.",
|
||||
"Resource guards bound stable artifact reads to 256 MiB, aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete metadata payloads to 8 MiB; these are not operating-system memory or CPU limits.",
|
||||
...(info !== null &&
|
||||
(info.go_version === null || info.module_text === null)
|
||||
? [
|
||||
"Some embedded Go strings contain non-UTF8 bytes; undecodable text is null and exact source bytes are preserved as base64.",
|
||||
]
|
||||
: []),
|
||||
...(info === null
|
||||
? [
|
||||
"No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.",
|
||||
]
|
||||
: []),
|
||||
...(module?.complete === false
|
||||
? [
|
||||
"Some embedded module-text records could not be parsed completely; their original lines are preserved and additional metadata remains unknown.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = goBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Go reader produced invalid source ranges or report values.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(parsed.data);
|
||||
} catch (cause: unknown) {
|
||||
return err(failureFor(cause, input.path, options?.signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
mappedFileOffset,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
/** Read ELF build-info sections and file-backed program mappings. */
|
||||
export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
if (bytes.length < 16)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF identification is truncated",
|
||||
);
|
||||
const bits = bytes[4] === 1 ? 32 : bytes[4] === 2 ? 64 : null;
|
||||
if (bits === null || ![1, 2].includes(bytes[5] ?? 0) || bytes[6] !== 1)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF class, byte order, or version is invalid",
|
||||
);
|
||||
const reader = new GoBinaryReader(bytes, bytes[5] === 1);
|
||||
const wide = bits === 64;
|
||||
const minimum = wide ? 64 : 52;
|
||||
reader.range(0, minimum, "ELF header");
|
||||
if (reader.u32(20) !== 1 || reader.u16(wide ? 52 : 40) < minimum)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF header version or size is invalid",
|
||||
);
|
||||
const headerSize = reader.u16(wide ? 52 : 40);
|
||||
reader.range(0, headerSize, "ELF declared header");
|
||||
const phOffset = reader.word(wide ? 32 : 28, bits);
|
||||
const shOffset = reader.word(wide ? 40 : 32, bits);
|
||||
const phWidth = reader.u16(wide ? 54 : 42);
|
||||
const shWidth = reader.u16(wide ? 58 : 46);
|
||||
let phCount = reader.u16(wide ? 56 : 44);
|
||||
let shCount = reader.u16(wide ? 60 : 48);
|
||||
let namesIndex = reader.u16(wide ? 62 : 50);
|
||||
if (
|
||||
(phCount !== 0 && phOffset < BigInt(headerSize)) ||
|
||||
(shOffset !== 0n && shOffset < BigInt(headerSize))
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF header table overlaps the native header",
|
||||
);
|
||||
if (
|
||||
shOffset !== 0n &&
|
||||
(shCount === 0 || phCount === 0xffff || namesIndex === 0xffff)
|
||||
) {
|
||||
if (shWidth < (wide ? 64 : 40))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF extended section header width is invalid",
|
||||
);
|
||||
const first = reader.fileRange(
|
||||
shOffset,
|
||||
BigInt(shWidth),
|
||||
"ELF section zero",
|
||||
).offset;
|
||||
if (reader.u32(first + 4) !== 0)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF initial section for extended numbering must have SHT_NULL type",
|
||||
);
|
||||
if (shCount === 0) {
|
||||
const count = reader.word(first + (wide ? 32 : 20), bits);
|
||||
if (count < 0xff00n || count > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF extended section count is invalid",
|
||||
);
|
||||
shCount = Number(count);
|
||||
}
|
||||
if (phCount === 0xffff) {
|
||||
phCount = reader.u32(first + (wide ? 44 : 28));
|
||||
if (phCount < 0xffff)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF extended program count is below its reserved range",
|
||||
);
|
||||
}
|
||||
if (namesIndex === 0xffff) {
|
||||
namesIndex = reader.u32(first + (wide ? 40 : 24));
|
||||
if (namesIndex < 0xff00)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF extended section name index is below its reserved range",
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
shOffset === 0n &&
|
||||
(shCount !== 0 || namesIndex !== 0 || phCount === 0xffff)
|
||||
) {
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF section table is missing",
|
||||
);
|
||||
}
|
||||
if (phCount !== 0 && phWidth < (wide ? 56 : 32))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF program header width is invalid",
|
||||
);
|
||||
const phStart = reader.table(
|
||||
phOffset,
|
||||
phCount,
|
||||
phWidth,
|
||||
"ELF program headers",
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
let fallback: GoFileMapping | null = null;
|
||||
for (let index = 0; index < phCount; index++) {
|
||||
const offset = phStart + index * phWidth;
|
||||
if (reader.u32(offset) !== 1) continue;
|
||||
const flags = reader.u32(offset + (wide ? 4 : 24));
|
||||
const fileOffset = reader.word(offset + (wide ? 8 : 4), bits);
|
||||
const address = reader.word(offset + (wide ? 16 : 8), bits);
|
||||
const size = reader.word(offset + (wide ? 32 : 16), bits);
|
||||
const memorySize = reader.word(offset + (wide ? 40 : 20), bits);
|
||||
if (size > memorySize || address + memorySize > 1n << BigInt(bits))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF load segment sizes or addresses are invalid",
|
||||
);
|
||||
const mapping = {
|
||||
...(size === 0n
|
||||
? { offset: 0, size: 0 }
|
||||
: reader.fileRange(fileOffset, size, "ELF load segment")),
|
||||
address,
|
||||
};
|
||||
if (mapping.size !== 0) mappings.push(mapping);
|
||||
if (memorySize > size)
|
||||
zeroFills.push({ address: address + size, size: memorySize - size });
|
||||
if (fallback === null && (flags & 3) === 2) fallback = mapping;
|
||||
}
|
||||
let search: GoFileMapping | null = null;
|
||||
if (shCount !== 0) {
|
||||
if (shWidth < (wide ? 64 : 40) || namesIndex >= shCount)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF section width or name-table index is invalid",
|
||||
);
|
||||
const shStart = reader.table(
|
||||
shOffset,
|
||||
shCount,
|
||||
shWidth,
|
||||
"ELF section headers",
|
||||
);
|
||||
if (namesIndex !== 0) {
|
||||
const namesHeader = shStart + namesIndex * shWidth;
|
||||
if (reader.u32(namesHeader + 4) !== 3)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF section name table is not a string table",
|
||||
);
|
||||
const names = reader.fileRange(
|
||||
reader.word(namesHeader + (wide ? 24 : 16), bits),
|
||||
reader.word(namesHeader + (wide ? 32 : 20), bits),
|
||||
"ELF section names",
|
||||
);
|
||||
for (let index = 0; index < shCount; index++) {
|
||||
const offset = shStart + index * shWidth;
|
||||
const nameOffset = reader.u32(offset);
|
||||
if (nameOffset >= names.size)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF section name offset is outside its string table",
|
||||
);
|
||||
// Comparing the complete fixed name avoids quadratic scans through malicious string tables.
|
||||
const expectedName = Buffer.from(".go.buildinfo\0");
|
||||
if (
|
||||
expectedName.length > names.size - nameOffset ||
|
||||
!bytes
|
||||
.subarray(
|
||||
names.offset + nameOffset,
|
||||
names.offset + nameOffset + expectedName.length,
|
||||
)
|
||||
.equals(expectedName)
|
||||
)
|
||||
continue;
|
||||
if (search !== null)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF contains ambiguous build-info sections",
|
||||
);
|
||||
if (reader.u32(offset + 4) !== 1)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF build-info section is not file-backed data",
|
||||
);
|
||||
if ((reader.word(offset + 8, bits) & 0x800n) !== 0n)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Compressed ELF build-info sections are unsupported",
|
||||
);
|
||||
const address = reader.word(offset + (wide ? 16 : 12), bits);
|
||||
search = {
|
||||
...reader.fileRange(
|
||||
reader.word(offset + (wide ? 24 : 16), bits),
|
||||
reader.word(offset + (wide ? 32 : 20), bits),
|
||||
"ELF build-info section",
|
||||
),
|
||||
address,
|
||||
};
|
||||
if (
|
||||
mappedFileOffset(mappings, address, search.size, zeroFills) !==
|
||||
search.offset
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"ELF build-info section disagrees with its load mapping",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const machine = reader.u16(18);
|
||||
return {
|
||||
format: "elf",
|
||||
architecture: elfArchitecture(machine),
|
||||
bits,
|
||||
byte_order: reader.little ? "little" : "big",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
search: search ?? fallback,
|
||||
};
|
||||
};
|
||||
|
||||
const elfArchitecture = (machine: number): string => {
|
||||
const names = new Map([
|
||||
[3, "386"],
|
||||
[8, "mips"],
|
||||
[20, "ppc"],
|
||||
[21, "ppc64"],
|
||||
[22, "s390"],
|
||||
[40, "arm"],
|
||||
[62, "amd64"],
|
||||
[183, "arm64"],
|
||||
[243, "riscv"],
|
||||
[258, "loongarch"],
|
||||
]);
|
||||
return names.get(machine) ?? `elf-machine-${machine}`;
|
||||
};
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
mappedFileOffset,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
/** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */
|
||||
export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
|
||||
const bits = magic === 0xfeedfacf || magic === 0xcffaedfe ? 64 : 32;
|
||||
const reader = new GoBinaryReader(bytes, little);
|
||||
const headerSize = bits === 64 ? 32 : 28;
|
||||
reader.range(0, headerSize, "Mach-O header");
|
||||
const count = reader.u32(16);
|
||||
const commandBytes = reader.u32(20);
|
||||
const commandStart = reader.table(
|
||||
BigInt(headerSize),
|
||||
commandBytes,
|
||||
1,
|
||||
"Mach-O load commands",
|
||||
);
|
||||
if (count > Math.floor(commandBytes / 8))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O command count exceeds its load-command range",
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
const named: GoFileMapping[] = [];
|
||||
let fallback: GoFileMapping | null = null;
|
||||
let offset = commandStart;
|
||||
const end = commandStart + commandBytes;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const command = reader.u32(offset);
|
||||
const size = reader.u32(offset + 4);
|
||||
if (size < 8 || size % 4 !== 0 || size > end - offset)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O load command size is invalid or truncated",
|
||||
);
|
||||
if (command === 1 || command === 0x19) {
|
||||
const wide = command === 0x19;
|
||||
if (wide !== (bits === 64))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O segment command does not match the header width",
|
||||
);
|
||||
const segmentHeader = wide ? 72 : 56;
|
||||
const sectionWidth = wide ? 80 : 68;
|
||||
if (size < segmentHeader)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O segment header is truncated",
|
||||
);
|
||||
const address = reader.word(offset + 24, bits);
|
||||
const memorySize = reader.word(offset + (wide ? 32 : 28), bits);
|
||||
const fileOffset = reader.word(offset + (wide ? 40 : 32), bits);
|
||||
const fileSize = reader.word(offset + (wide ? 48 : 36), bits);
|
||||
const maxProtection = reader.u32(offset + (wide ? 56 : 40));
|
||||
const protection = reader.u32(offset + (wide ? 60 : 44));
|
||||
const sectionCount = reader.u32(offset + (wide ? 64 : 48));
|
||||
if (fileSize > memorySize || address + memorySize > 1n << BigInt(bits))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O segment addresses or sizes are invalid",
|
||||
);
|
||||
if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O section table exceeds its segment command",
|
||||
);
|
||||
const mapping = {
|
||||
...(fileSize === 0n
|
||||
? { offset: 0, size: 0 }
|
||||
: reader.fileRange(fileOffset, fileSize, "Mach-O segment")),
|
||||
address,
|
||||
};
|
||||
const name = fixedName(
|
||||
reader.range(offset + 8, 16, "Mach-O segment name"),
|
||||
);
|
||||
if (mapping.size !== 0 && name !== "__PAGEZERO") mappings.push(mapping);
|
||||
if (memorySize > fileSize && name !== "__PAGEZERO")
|
||||
zeroFills.push({
|
||||
address: address + fileSize,
|
||||
size: memorySize - fileSize,
|
||||
});
|
||||
if (
|
||||
fallback === null &&
|
||||
address !== 0n &&
|
||||
mapping.size !== 0 &&
|
||||
protection === 3 &&
|
||||
maxProtection === 3
|
||||
)
|
||||
fallback = mapping;
|
||||
for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
|
||||
const section = offset + segmentHeader + sectionIndex * sectionWidth;
|
||||
if (
|
||||
fixedName(reader.range(section, 16, "Mach-O section name")) !==
|
||||
"__go_buildinfo"
|
||||
)
|
||||
continue;
|
||||
const sectionFlags = reader.u32(section + (wide ? 64 : 56));
|
||||
if ([1, 0xc, 0x12].includes(sectionFlags & 0xff))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O build-info section is zero-fill data",
|
||||
);
|
||||
const sectionAddress = reader.word(section + 32, bits);
|
||||
const sectionSize = reader.word(section + (wide ? 40 : 36), bits);
|
||||
const sectionOffset = BigInt(reader.u32(section + (wide ? 48 : 40)));
|
||||
const info = {
|
||||
...reader.fileRange(
|
||||
sectionOffset,
|
||||
sectionSize,
|
||||
"Mach-O build-info section",
|
||||
),
|
||||
address: sectionAddress,
|
||||
};
|
||||
if (
|
||||
mappedFileOffset([mapping], sectionAddress, info.size) !== info.offset
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O build-info section disagrees with its segment",
|
||||
);
|
||||
named.push(info);
|
||||
}
|
||||
}
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== end)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O load-command byte count does not match its commands",
|
||||
);
|
||||
if (named.length > 1)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O has ambiguous build-info sections",
|
||||
);
|
||||
const cpu = reader.u32(4);
|
||||
const names = new Map([
|
||||
[7, "386"],
|
||||
[0x01000007, "amd64"],
|
||||
[12, "arm"],
|
||||
[0x0100000c, "arm64"],
|
||||
[18, "ppc"],
|
||||
[0x01000012, "ppc64"],
|
||||
]);
|
||||
return {
|
||||
format: "macho",
|
||||
architecture: names.get(cpu) ?? `macho-cpu-${cpu}`,
|
||||
bits,
|
||||
byte_order: little ? "little" : "big",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
search: named[0] ?? fallback,
|
||||
};
|
||||
};
|
||||
|
||||
const fixedName = (bytes: Buffer): string => {
|
||||
const nul = bytes.indexOf(0);
|
||||
return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1");
|
||||
};
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import type { GoModule, GoModuleMetadata } from "../domain/go/goBinary.js";
|
||||
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
const escapes = new Map([
|
||||
["a", 7],
|
||||
["b", 8],
|
||||
["f", 12],
|
||||
["n", 10],
|
||||
["r", 13],
|
||||
["t", 9],
|
||||
["v", 11],
|
||||
["\\", 92],
|
||||
['"', 34],
|
||||
]);
|
||||
|
||||
const quoted = (input: string): { value: string; end: number } | null => {
|
||||
if (input.startsWith("`")) {
|
||||
const end = input.indexOf("`", 1);
|
||||
return end < 0
|
||||
? null
|
||||
: { value: input.slice(1, end).replace(/\r/g, ""), end: end + 1 };
|
||||
}
|
||||
if (!input.startsWith('"')) return null;
|
||||
const output = Buffer.alloc(input.length * 3);
|
||||
let length = 0;
|
||||
for (let index = 1; index < input.length;) {
|
||||
const character = input[index];
|
||||
if (character === '"') {
|
||||
try {
|
||||
return {
|
||||
value: utf8.decode(output.subarray(0, length)),
|
||||
end: index + 1,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (character === "\n") return null;
|
||||
if (character !== "\\") {
|
||||
const point = input.codePointAt(index);
|
||||
if (point === undefined) return null;
|
||||
length += output.write(String.fromCodePoint(point), length, "utf8");
|
||||
index += point > 0xffff ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
const escaped = input[index + 1];
|
||||
if (escaped === undefined) return null;
|
||||
const simple = escapes.get(escaped);
|
||||
if (simple !== undefined) {
|
||||
output[length++] = simple;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
const digits =
|
||||
escaped === "x" ? 2 : escaped === "u" ? 4 : escaped === "U" ? 8 : 0;
|
||||
if (digits > 0) {
|
||||
const encoded = input.slice(index + 2, index + 2 + digits);
|
||||
if (encoded.length !== digits || !/^[a-f\d]+$/i.test(encoded))
|
||||
return null;
|
||||
const point = Number.parseInt(encoded, 16);
|
||||
if (escaped === "x") output[length++] = point;
|
||||
else {
|
||||
if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff))
|
||||
return null;
|
||||
length += output.write(String.fromCodePoint(point), length, "utf8");
|
||||
}
|
||||
index += 2 + digits;
|
||||
continue;
|
||||
}
|
||||
const octal = input.slice(index + 1, index + 4);
|
||||
if (!/^[0-7]{3}$/.test(octal)) return null;
|
||||
const byte = Number.parseInt(octal, 8);
|
||||
if (byte > 255) return null;
|
||||
output[length++] = byte;
|
||||
index += 4;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setting = (input: string): { key: string; value: string } | null => {
|
||||
let key: string;
|
||||
let rawValue: string;
|
||||
if (input.startsWith('"') || input.startsWith("`")) {
|
||||
const decoded = quoted(input);
|
||||
if (decoded === null || input[decoded.end] !== "=") return null;
|
||||
key = decoded.value;
|
||||
rawValue = input.slice(decoded.end + 1);
|
||||
} else {
|
||||
const separator = input.indexOf("=");
|
||||
if (separator < 1) return null;
|
||||
key = input.slice(0, separator);
|
||||
if (/[= \t\r\n"`]/.test(key)) return null;
|
||||
rawValue = input.slice(separator + 1);
|
||||
}
|
||||
if (rawValue.startsWith('"') || rawValue.startsWith("`")) {
|
||||
const decoded = quoted(rawValue);
|
||||
return decoded !== null && decoded.end === rawValue.length
|
||||
? { key, value: decoded.value }
|
||||
: null;
|
||||
}
|
||||
return /[ \t\r\n"`]/.test(rawValue) ? null : { key, value: rawValue };
|
||||
};
|
||||
|
||||
const moduleLine = (fields: string[]): GoModule | null => {
|
||||
const [path, version] = fields;
|
||||
if (
|
||||
(fields.length !== 2 && fields.length !== 3) ||
|
||||
path === undefined ||
|
||||
version === undefined
|
||||
)
|
||||
return null;
|
||||
return { path, version, sum: fields[2] ?? null, replacement: null };
|
||||
};
|
||||
|
||||
interface ModuleSourceLine {
|
||||
readonly line: string | Buffer;
|
||||
readonly terminated: boolean;
|
||||
}
|
||||
|
||||
function* moduleSourceLines(
|
||||
source: string | Buffer,
|
||||
): Generator<ModuleSourceLine> {
|
||||
let start = 0;
|
||||
while (start < source.length) {
|
||||
const newline =
|
||||
typeof source === "string"
|
||||
? source.indexOf("\n", start)
|
||||
: source.indexOf(10, start);
|
||||
const end = newline < 0 ? source.length : newline;
|
||||
let line: string | Buffer;
|
||||
if (typeof source === "string") line = source.slice(start, end);
|
||||
else {
|
||||
const bytes = source.subarray(start, end);
|
||||
try {
|
||||
line = utf8.decode(bytes);
|
||||
} catch {
|
||||
line = bytes;
|
||||
}
|
||||
}
|
||||
yield { line, terminated: newline >= 0 };
|
||||
if (newline < 0) return;
|
||||
start = newline + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const modulePrefixes = ["mod\t", "dep\t", "=>\t"].map((prefix) =>
|
||||
Buffer.from(prefix),
|
||||
);
|
||||
|
||||
const parseModuleRecords = (source: string | Buffer): GoModuleMetadata => {
|
||||
let path: string | null = null;
|
||||
let main: GoModule | null = null;
|
||||
const dependencies: GoModule[] = [];
|
||||
const settings: { key: string; value: string }[] = [];
|
||||
const unparsed_lines: string[] = [];
|
||||
const unparsed_line_bytes_base64: string[] = [];
|
||||
let last: GoModule | null = null;
|
||||
for (const { line, terminated } of moduleSourceLines(source)) {
|
||||
if (typeof line !== "string") {
|
||||
unparsed_line_bytes_base64.push(line.toString("base64"));
|
||||
if (
|
||||
modulePrefixes.some((prefix) =>
|
||||
line.subarray(0, prefix.length).equals(prefix),
|
||||
)
|
||||
)
|
||||
last = null;
|
||||
continue;
|
||||
}
|
||||
if (line === "") continue;
|
||||
if (!terminated) {
|
||||
unparsed_lines.push(line);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("path\t") && path === null) {
|
||||
path = line.slice(5);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("mod\t") || line.startsWith("dep\t")) {
|
||||
const value = moduleLine(line.slice(4).split("\t"));
|
||||
last = null;
|
||||
if (value !== null && (!line.startsWith("mod\t") || main === null)) {
|
||||
if (line.startsWith("mod\t")) main = value;
|
||||
else dependencies.push(value);
|
||||
last = value;
|
||||
continue;
|
||||
}
|
||||
} else if (line.startsWith("=>\t")) {
|
||||
const fields = line.slice(3).split("\t");
|
||||
const value = moduleLine(fields);
|
||||
if (fields.length === 3 && value !== null && last !== null) {
|
||||
last.replacement = {
|
||||
path: value.path,
|
||||
version: value.version,
|
||||
sum: value.sum,
|
||||
};
|
||||
last = null;
|
||||
continue;
|
||||
}
|
||||
last = null;
|
||||
} else if (line.startsWith("build\t")) {
|
||||
const value = setting(line.slice(6));
|
||||
if (value !== null) {
|
||||
settings.push(value);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unparsed_lines.push(line);
|
||||
}
|
||||
return {
|
||||
path,
|
||||
main,
|
||||
dependencies,
|
||||
settings,
|
||||
unparsed_lines,
|
||||
unparsed_line_bytes_base64,
|
||||
complete: unparsed_lines.length + unparsed_line_bytes_base64.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
/** Decode Go's module records while preserving unsupported/malformed source lines. */
|
||||
export const parseGoModuleText = (text: string): GoModuleMetadata =>
|
||||
parseModuleRecords(text);
|
||||
|
||||
/** Decode an unframed Go module body while retaining non-UTF-8 line bytes exactly. */
|
||||
export const parseGoModuleBytes = (bytes: Buffer): GoModuleMetadata =>
|
||||
parseModuleRecords(bytes);
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { mzWindowsHeaderOffset, parseDosMzHeader } from "../domain/dosMz.js";
|
||||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
const peHeaderOffset = (reader: GoBinaryReader): number => {
|
||||
const pe = mzWindowsHeaderOffset(reader.bytes);
|
||||
if (pe === null) {
|
||||
const dos = parseDosMzHeader(reader.bytes);
|
||||
if (!dos.ok) throw new GoBinaryFormatFailure("malformed", dos.error);
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"DOS MZ images are outside the supported ELF, PE and thin Mach-O formats",
|
||||
);
|
||||
}
|
||||
if (pe < 64)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE signature overlaps the DOS header",
|
||||
);
|
||||
const signature = reader
|
||||
.range(pe, 2, "Windows new-header signature")
|
||||
.toString("latin1");
|
||||
if (["NE", "LE", "LX"].includes(signature))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
`${signature} images are outside the supported ELF, PE and thin Mach-O formats`,
|
||||
);
|
||||
return pe;
|
||||
};
|
||||
|
||||
const optionalHeaderMagic = (
|
||||
reader: GoBinaryReader,
|
||||
offset: number,
|
||||
size: number,
|
||||
): number => {
|
||||
reader.range(offset, size, "PE optional header");
|
||||
if (size < 2)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE optional header is missing its complete magic field",
|
||||
);
|
||||
return reader.u16(offset);
|
||||
};
|
||||
|
||||
/** Read PE32/PE32+ native image mappings and the linker data section. */
|
||||
export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
const reader = new GoBinaryReader(bytes, true);
|
||||
const pe = peHeaderOffset(reader);
|
||||
if (
|
||||
!reader
|
||||
.range(pe, 24, "PE signature and COFF header")
|
||||
.subarray(0, 4)
|
||||
.equals(Buffer.from("PE\0\0"))
|
||||
)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE signature is invalid or overlaps the DOS header",
|
||||
);
|
||||
const optional = pe + 24;
|
||||
const optionalSize = reader.u16(pe + 20);
|
||||
const magic = optionalHeaderMagic(reader, optional, optionalSize);
|
||||
const bits = magic === 0x10b ? 32 : magic === 0x20b ? 64 : null;
|
||||
if (bits === null)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"unsupported",
|
||||
"Only PE32 and PE32+ image optional headers are supported",
|
||||
);
|
||||
if (optionalSize < (bits === 64 ? 112 : 96))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE optional header is truncated",
|
||||
);
|
||||
const directoriesOffset = bits === 64 ? 112 : 96;
|
||||
const directories = reader.u32(optional + directoriesOffset - 4);
|
||||
if (directories * 8 > optionalSize - directoriesOffset)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE data directories extend beyond the optional header",
|
||||
);
|
||||
const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits);
|
||||
const count = reader.u16(pe + 6);
|
||||
const start = reader.table(
|
||||
BigInt(optional + optionalSize),
|
||||
count,
|
||||
40,
|
||||
"PE sections",
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
let search: GoFileMapping | null = null;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const entry = start + index * 40;
|
||||
const virtualSize = reader.u32(entry + 8);
|
||||
const virtualAddress = reader.u32(entry + 12);
|
||||
const size = reader.u32(entry + 16);
|
||||
const offset = reader.u32(entry + 20);
|
||||
const characteristics = reader.u32(entry + 36);
|
||||
const address = imageBase + BigInt(virtualAddress);
|
||||
if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits))
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
"PE section virtual address overflows its address width",
|
||||
);
|
||||
const mapping = {
|
||||
...(size === 0
|
||||
? { offset: 0, size: 0 }
|
||||
: reader.fileRange(BigInt(offset), BigInt(size), "PE section")),
|
||||
address,
|
||||
};
|
||||
if (size !== 0) mappings.push(mapping);
|
||||
if (virtualSize > size)
|
||||
zeroFills.push({
|
||||
address: address + BigInt(size),
|
||||
size: BigInt(virtualSize - size),
|
||||
});
|
||||
// Match debug/buildinfo's first initialized, readable, writable data section.
|
||||
if (
|
||||
search === null &&
|
||||
virtualAddress !== 0 &&
|
||||
size !== 0 &&
|
||||
(characteristics & ~0x00600000) >>> 0 === 0xc0000040
|
||||
) {
|
||||
search = { ...mapping, size: Math.min(size, virtualSize) };
|
||||
}
|
||||
}
|
||||
const machine = reader.u16(pe + 4);
|
||||
const names = new Map([
|
||||
[0x014c, "386"],
|
||||
[0x8664, "amd64"],
|
||||
[0x01c0, "arm"],
|
||||
[0x01c2, "arm"],
|
||||
[0x01c4, "arm"],
|
||||
[0xaa64, "arm64"],
|
||||
]);
|
||||
return {
|
||||
format: "pe",
|
||||
architecture: names.get(machine) ?? `pe-machine-${machine}`,
|
||||
bits,
|
||||
byte_order: "little",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
search,
|
||||
};
|
||||
};
|
||||
|
|
@ -177,6 +177,9 @@ export class PwntoolsDecoder<
|
|||
requestPath,
|
||||
],
|
||||
cwd: root.path,
|
||||
// Caller-selected Python launchers can exec through prlimit or a
|
||||
// shell wrapper. Keep parent/group/run-token ownership across exec.
|
||||
expectedCommand: null,
|
||||
runId: `rea-artifact-decoder-${randomUUID()}`,
|
||||
hostEnvironment: {
|
||||
...this.environment,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { createEvmInterfaceService } from "../composition/evm.js";
|
|||
import { registerEvmTools } from "./registerEvmTools.js";
|
||||
import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js";
|
||||
import { createRecordedCrashService } from "../composition/binaryDiagnostics.js";
|
||||
import { createGoBinaryService } from "../composition/go.js";
|
||||
import type { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import { registerGoTools } from "./registerGoTools.js";
|
||||
import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
|
||||
import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js";
|
||||
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
|
||||
|
|
@ -110,6 +113,7 @@ export interface CreateServerOptions {
|
|||
readonly evmInterface?: EvmInterfaceService;
|
||||
readonly logger?: Logger;
|
||||
readonly binaryLayout?: BinaryLayoutService;
|
||||
readonly goBinary?: GoBinaryService;
|
||||
readonly recordedCrash?: RecordedCrashService;
|
||||
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
||||
readonly javascriptRecovery?: JavaScriptRecoveryPort;
|
||||
|
|
@ -435,6 +439,13 @@ const registerConfiguredAnalysisTools = (
|
|||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerGoTools(
|
||||
server,
|
||||
options.goBinary ?? createGoBinaryService(),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerWasmTools(
|
||||
server,
|
||||
wasmArtifact,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
|
||||
/** Bind the exact Go contract to its shared workflow and owning session Evidence ledger. */
|
||||
export const registerGoTools = (
|
||||
server: EvidenceMcpServer,
|
||||
service: GoBinaryService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_go_binary");
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
};
|
||||
|
|
@ -21,12 +21,19 @@ const fixturePath = fileURLToPath(
|
|||
new URL("../../fixtures/fakeLauncher.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
const expectAvailableToolInventory = async (client: Client) => {
|
||||
const listed = await client.listTools();
|
||||
const status = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {},
|
||||
});
|
||||
const expectAvailableToolInventory = async (
|
||||
client: Client,
|
||||
listed: Awaited<ReturnType<Client["listTools"]>>,
|
||||
) => {
|
||||
const binarySession = listed.tools.find(
|
||||
({ name }) => name === "binary_session",
|
||||
);
|
||||
if (binarySession === undefined)
|
||||
throw new Error("The compiled catalog omitted binary_session");
|
||||
const status = await client.callTool(
|
||||
{ name: "binary_session", arguments: {} },
|
||||
{ toolDefinition: binarySession },
|
||||
);
|
||||
expect(status.isError).not.toBe(true);
|
||||
const inventory = z
|
||||
.object({
|
||||
|
|
@ -164,6 +171,9 @@ describe("production stdio runtime", () => {
|
|||
15_000,
|
||||
);
|
||||
|
||||
// Use the suite's bounded acceptance deadline, including its coverage allowance.
|
||||
// This cold-start workflow computes the growing catalog's schema digests; it
|
||||
// verifies catalog and lifecycle behavior, not a 15-second performance target.
|
||||
it("serves the compiled catalog without an initial target or fatal record", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
|
|
@ -183,7 +193,10 @@ describe("production stdio runtime", () => {
|
|||
try {
|
||||
await client.connect(transport);
|
||||
// Keep catalog parity independent of initial target/provider startup.
|
||||
const status = await expectAvailableToolInventory(client);
|
||||
// Validate each called tool against its advertised definition without
|
||||
// asking the SDK to compile every unrelated output schema in the catalog.
|
||||
const listed = await client.listTools();
|
||||
const status = await expectAvailableToolInventory(client, listed);
|
||||
expect(
|
||||
z
|
||||
.object({ result: z.object({ open: z.boolean() }) })
|
||||
|
|
@ -191,10 +204,15 @@ describe("production stdio runtime", () => {
|
|||
).toEqual({ open: false });
|
||||
// Closing the empty session produces a structured lifecycle record,
|
||||
// making the fatal-record check observable without launching a provider.
|
||||
const closed = await client.callTool({
|
||||
name: "close_binary",
|
||||
arguments: {},
|
||||
});
|
||||
const closeBinary = listed.tools.find(
|
||||
({ name }) => name === "close_binary",
|
||||
);
|
||||
if (closeBinary === undefined)
|
||||
throw new Error("The compiled catalog omitted close_binary");
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
{ toolDefinition: closeBinary },
|
||||
);
|
||||
expect(closed.isError).not.toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
|
|
@ -221,10 +239,7 @@ describe("production stdio runtime", () => {
|
|||
record.level >= 50,
|
||||
);
|
||||
expect(fatal).toEqual([]);
|
||||
// Full catalog discovery and first-call SDK schema compilation take about
|
||||
// 8–9 seconds on one local CPU; the 15-second budget repeatedly expired in
|
||||
// parallel CI shards. Keep a finite budget for the complete workflow.
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "darwin")(
|
||||
"serves a database-kind initial target without a fatal record on macOS",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
createGoBinaryByteStringFixture,
|
||||
createGoBinaryDiagnosticFixtures,
|
||||
createGoBinaryFixture,
|
||||
createGoBinaryPathFailureFixture,
|
||||
GO_MODULE_TEXT,
|
||||
} from "../fixtures/go/image.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) {
|
||||
cliTest.skipIf(expectedErrno === "ELOOP" && process.platform === "win32")(
|
||||
`reports native ${expectedErrno} path failure as invalid selected-path input through the public Go CLI`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-path-failure-");
|
||||
const { path, errno } = await createGoBinaryPathFailureFixture(
|
||||
root,
|
||||
expectedErrno,
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_go_binary",
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(errno),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.json).toMatchObject({
|
||||
details: { issues: [{ message: expect.stringContaining(path) }] },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cliTest(
|
||||
"preserves invalid embedded Go text bytes through the public CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-byte-strings-");
|
||||
const path = join(root, "byte-strings.elf");
|
||||
const fixture = createGoBinaryByteStringFixture();
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.normalized_result.build_info).toEqual(
|
||||
fixture.expectedBuildInfo,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const fixture of createGoBinaryDiagnosticFixtures()) {
|
||||
cliTest(
|
||||
`reports ${fixture.code} for ${fixture.name} through the public Go CLI`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-mz-");
|
||||
const path = join(root, `${fixture.name}.exe`);
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: fixture.code,
|
||||
details: {
|
||||
...fixture.details,
|
||||
...(fixture.code === "unsupported_target" ? { path } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cliTest(
|
||||
"reports a readable unsupported carrier through the public Go CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-");
|
||||
const path = join(root, "text.bin");
|
||||
await writeFile(path, "This is not an executable container.");
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: { operation: "inspect_go_binary", path },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
for (const format of ["elf", "pe", "macho"] as const) {
|
||||
for (const encoding of ["inline", "pointer"] as const) {
|
||||
cliTest(
|
||||
`inspects authored ${format} ${encoding} build metadata through the compiled CLI`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-");
|
||||
const fixture = createGoBinaryFixture({ format, encoding });
|
||||
const path = join(root, "application.bin");
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", "application.bin", "--json"],
|
||||
cwd: root,
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.raw_result).toBeNull();
|
||||
expect(evidence.subject?.local_path).toBe(path);
|
||||
expect(evidence.subject?.digest.sha256).toBe(
|
||||
createHash("sha256").update(fixture.bytes).digest("hex"),
|
||||
);
|
||||
const report = evidence.normalized_result;
|
||||
expect(report).toMatchObject({
|
||||
format,
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
artifact: { path, bytes: fixture.bytes.length },
|
||||
});
|
||||
expect(report.build_info).toMatchObject({
|
||||
header_offset: fixture.headerOffset,
|
||||
encoding,
|
||||
go_version: "go1.26.0",
|
||||
module_text: GO_MODULE_TEXT,
|
||||
module_bytes_base64: fixture.moduleBytes.toString("base64"),
|
||||
module: {
|
||||
path: "example.com/tool/cmd/tool",
|
||||
main: {
|
||||
path: "example.com/tool",
|
||||
version: "(devel)",
|
||||
sum: "",
|
||||
replacement: null,
|
||||
},
|
||||
dependencies: [
|
||||
{
|
||||
path: "example.com/dependency",
|
||||
version: "v1.2.3",
|
||||
sum: "h1:sample",
|
||||
replacement: { path: "../dependency", version: "", sum: "" },
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
unparsed_lines: [],
|
||||
},
|
||||
});
|
||||
expect(report.build_info?.module.settings).toContainEqual({
|
||||
key: "-ldflags",
|
||||
value: "-s -w",
|
||||
});
|
||||
if (report.build_info === null)
|
||||
throw new Error("Fixture build metadata must be present");
|
||||
const version = report.build_info.version_location;
|
||||
const module = report.build_info.module_location;
|
||||
expect(
|
||||
fixture.bytes
|
||||
.subarray(version.offset, version.offset + version.bytes)
|
||||
.toString("utf8"),
|
||||
).toBe(report.build_info.go_version);
|
||||
expect(
|
||||
fixture.bytes.subarray(module.offset, module.offset + module.bytes),
|
||||
).toEqual(Buffer.from(report.build_info.module_bytes_base64, "base64"));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cliTest("preserves MIPS identity in Go CLI Evidence", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-mips-");
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.writeUInt16LE(8, 18); // ELF EM_MIPS.
|
||||
const path = join(root, "application.elf");
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.subject?.architecture).toBe("mips");
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
architecture: "mips",
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
});
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"reports missing build information as unknown and preserves unsupported carriers distinctly",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-go-cli-");
|
||||
const path = join(root, "metadata-free.elf");
|
||||
await writeFile(path, createGoBinaryFixture({ omitBuildInfo: true }).bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-go-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const report = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
result.json,
|
||||
).normalized_result;
|
||||
expect(report.build_info).toBeNull();
|
||||
expect(report.limitations.join(" ")).toContain(
|
||||
"does not establish that the binary is not Go",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { PwntoolsLayoutProvider } from "../../../src/native/pwntools/PwntoolsLayoutProvider.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import {
|
||||
createTestWorkspace,
|
||||
removeTestWorkspace,
|
||||
} from "../../support/workspace/workspaceFixture.js";
|
||||
|
||||
// Exercise the decoder's launch/cleanup contract independently of the host's
|
||||
// process table. The interpreter has exec'd over its caller-selected wrapper.
|
||||
it
|
||||
.runIf(process.platform === "linux" && process.arch === "x64")
|
||||
.each(["owned", "wrong-token", "wrong-parent"] as const)(
|
||||
"cancels an exec wrapper only with current ownership: %s",
|
||||
async (identity) => {
|
||||
const { root } = await createTestWorkspace("rea-pwntools-wrapper-");
|
||||
onTestFinished(() => removeTestWorkspace(root));
|
||||
const path = join(root, "input");
|
||||
await writeFile(path, "source-owned-seam-bytes");
|
||||
const controller = new AbortController();
|
||||
class DecoderProcess extends EventEmitter {
|
||||
readonly pid = 41234;
|
||||
readonly stdout = new PassThrough();
|
||||
readonly stderr = new PassThrough();
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
kill(): boolean {
|
||||
throw new Error("Cleanup must validate ownership before signaling");
|
||||
}
|
||||
}
|
||||
const child = new DecoderProcess();
|
||||
let live = true;
|
||||
const signalGroup = vi.fn(() => {
|
||||
live = false;
|
||||
child.signalCode = "SIGTERM";
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
child.emit("close", null, "SIGTERM");
|
||||
});
|
||||
const provider = new PwntoolsLayoutProvider(
|
||||
{ REA_PWNTOOLS_PYTHON: process.execPath },
|
||||
async (spawn) => {
|
||||
const ownership = {
|
||||
runId: spawn.runId,
|
||||
leaderPid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
expectedParentPid: process.pid,
|
||||
...(spawn.expectedCommand === null
|
||||
? {}
|
||||
: { expectedCommand: spawn.expectedCommand ?? spawn.command }),
|
||||
};
|
||||
setImmediate(() => controller.abort());
|
||||
return {
|
||||
process: child,
|
||||
ownership,
|
||||
cleanup: () =>
|
||||
cleanupOwnedProcessGroup(ownership, {
|
||||
listProcesses: async () =>
|
||||
live
|
||||
? [
|
||||
{
|
||||
pid: child.pid,
|
||||
parentPid:
|
||||
identity === "wrong-parent"
|
||||
? process.pid + 1
|
||||
: process.pid,
|
||||
processGroupId: child.pid,
|
||||
state: "S",
|
||||
command:
|
||||
"/selected/python -I bridge/pwntools/layout.py",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
environment: async () => ({
|
||||
REA_PROCESS_RUN_ID:
|
||||
identity === "wrong-token" ? "another-run" : spawn.runId,
|
||||
}),
|
||||
signalGroup,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const result = await provider.inspect(
|
||||
{ path },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error:
|
||||
identity === "owned"
|
||||
? { _tag: "AnalysisCancelledError" }
|
||||
: { _tag: "ProviderAdapterError", cleanupIncomplete: true },
|
||||
});
|
||||
expect(signalGroup).toHaveBeenCalledTimes(identity === "owned" ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
|
@ -79,7 +79,12 @@ for (const format of ["directory", "asar"] as const) {
|
|||
map_path: "app.js.map",
|
||||
resolution: { kind: "artifact-relative", path: "original.js" },
|
||||
},
|
||||
properties: expect.objectContaining({ content_available: true }),
|
||||
properties: expect.objectContaining({
|
||||
content_available: true,
|
||||
source_name_available: true,
|
||||
source_index: 0,
|
||||
declaration_index: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,595 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js";
|
||||
import {
|
||||
createGoBinaryFixture,
|
||||
GO_MODULE_TEXT,
|
||||
} from "../../fixtures/go/image.js";
|
||||
|
||||
it.each([
|
||||
["elf", 32, "little"],
|
||||
["elf", 32, "big"],
|
||||
["elf", 64, "little"],
|
||||
["elf", 64, "big"],
|
||||
["pe", 32, "little"],
|
||||
["pe", 64, "little"],
|
||||
["macho", 32, "little"],
|
||||
["macho", 32, "big"],
|
||||
["macho", 64, "little"],
|
||||
["macho", 64, "big"],
|
||||
] as const)(
|
||||
"reads %s %d-bit %s inline build metadata",
|
||||
(format, bits, byteOrder) => {
|
||||
const fixture = createGoBinaryFixture({ format, bits, byteOrder });
|
||||
expect(readGoBinaryImage(fixture.bytes)).toMatchObject({
|
||||
format,
|
||||
bits,
|
||||
byte_order: byteOrder,
|
||||
build_info: {
|
||||
header_offset: fixture.headerOffset,
|
||||
encoding: "inline",
|
||||
go_version: "go1.26.0",
|
||||
module_text: GO_MODULE_TEXT,
|
||||
module_bytes_base64: fixture.moduleBytes.toString("base64"),
|
||||
version_location: { offset: fixture.versionOffset, bytes: 8 },
|
||||
module_location: {
|
||||
offset: fixture.moduleOffset,
|
||||
bytes: fixture.moduleBytes.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"follows mapped pointers in legacy %s metadata",
|
||||
(format) => {
|
||||
const fixture = createGoBinaryFixture({
|
||||
format,
|
||||
encoding: "pointer",
|
||||
goVersion: "go1.17",
|
||||
});
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toMatchObject({
|
||||
encoding: "pointer",
|
||||
go_version: "go1.17",
|
||||
module_text: GO_MODULE_TEXT,
|
||||
version_location: { offset: fixture.versionOffset, bytes: 6 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "macho"] as const)(
|
||||
"finds sectionless %s metadata in its writable data mapping",
|
||||
(format) => {
|
||||
const fixture = createGoBinaryFixture({ format, sectionless: true });
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.go_version).toBe(
|
||||
"go1.26.0",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat a code/overlay decoy or unaligned marker as Go metadata", () => {
|
||||
const absent = createGoBinaryFixture({ omitBuildInfo: true });
|
||||
const decoy = createGoBinaryFixture();
|
||||
decoy.bytes
|
||||
.subarray(decoy.headerOffset, decoy.headerOffset + 512)
|
||||
.copy(absent.bytes, 3072);
|
||||
expect(readGoBinaryImage(absent.bytes).build_info).toBeNull();
|
||||
const unaligned = createGoBinaryFixture({
|
||||
sectionless: true,
|
||||
omitBuildInfo: true,
|
||||
});
|
||||
decoy.bytes
|
||||
.subarray(decoy.headerOffset, decoy.headerOffset + 512)
|
||||
.copy(unaligned.bytes, 1025);
|
||||
expect(readGoBinaryImage(unaligned.bytes).build_info).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses mapped pointer lengths outside the file instead of rounding 64-bit addresses", () => {
|
||||
const fixture = createGoBinaryFixture({ encoding: "pointer" });
|
||||
fixture.bytes.writeBigUInt64LE(0x20000000000001n, 1536);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/mapped|address/i,
|
||||
);
|
||||
const length = createGoBinaryFixture({ encoding: "pointer" });
|
||||
length.bytes.writeBigUInt64LE(0xffffffffffffffffn, 1544);
|
||||
expect(() => readGoBinaryImage(length.bytes)).toThrowError(/limit|length/i);
|
||||
});
|
||||
|
||||
it("refuses overlong or truncated varints before decoding or allocating strings", () => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.fill(
|
||||
0xff,
|
||||
fixture.headerOffset + 32,
|
||||
fixture.headerOffset + 42,
|
||||
);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/varint/i);
|
||||
const truncated = createGoBinaryFixture();
|
||||
truncated.bytes.writeBigUInt64LE(33n, 256 + 64 * 2 + 32);
|
||||
truncated.bytes[truncated.headerOffset + 32] = 0x80;
|
||||
expect(() => readGoBinaryImage(truncated.bytes)).toThrowError(
|
||||
/truncated|range|varint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses invalid module framing and preserves BOMs without replacement decoding", () => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes[fixture.moduleOffset] = 0;
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/framing/i);
|
||||
const bom = createGoBinaryFixture({
|
||||
goVersion: "\ufeffgo1.26.0",
|
||||
moduleText: "\ufeffpath\tx\n",
|
||||
});
|
||||
expect(readGoBinaryImage(bom.bytes).build_info).toMatchObject({
|
||||
go_version: "\ufeffgo1.26.0",
|
||||
module_text: "\ufeffpath\tx\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects future encodings and invalid pointer widths explicitly", () => {
|
||||
const flags = createGoBinaryFixture();
|
||||
flags.bytes[flags.headerOffset + 15] = 0x82;
|
||||
expect(() => readGoBinaryImage(flags.bytes)).toThrowError(/flags/i);
|
||||
const width = createGoBinaryFixture({ encoding: "pointer" });
|
||||
width.bytes[width.headerOffset + 14] = 16;
|
||||
expect(() => readGoBinaryImage(width.bytes)).toThrowError(/pointer/i);
|
||||
});
|
||||
|
||||
it("preserves non-UTF8 compiler and module bytes without rejecting other observed metadata", () => {
|
||||
const version = createGoBinaryFixture();
|
||||
version.bytes[version.versionOffset] = 0xff;
|
||||
expect(readGoBinaryImage(version.bytes).build_info).toMatchObject({
|
||||
go_version: null,
|
||||
go_version_bytes_base64: version.bytes
|
||||
.subarray(version.versionOffset, version.versionOffset + 8)
|
||||
.toString("base64"),
|
||||
module_text: GO_MODULE_TEXT,
|
||||
});
|
||||
const module = createGoBinaryFixture({ moduleText: "build\t-tags=x\n" });
|
||||
module.bytes[module.moduleOffset + 16 + Buffer.byteLength("build\t-tags=")] =
|
||||
0xff;
|
||||
expect(readGoBinaryImage(module.bytes).build_info).toMatchObject({
|
||||
go_version: "go1.26.0",
|
||||
module_text: null,
|
||||
module_bytes_base64: module.bytes
|
||||
.subarray(
|
||||
module.moduleOffset,
|
||||
module.moduleOffset + module.moduleBytes.length,
|
||||
)
|
||||
.toString("base64"),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 4, 16])("ignores unused inline pointer width %s", (width) => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes[fixture.headerOffset + 14] = width;
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.go_version).toBe(
|
||||
"go1.26.0",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["pe", "macho"] as const)(
|
||||
"refuses ambiguous inline %s header mappings",
|
||||
(format) => {
|
||||
const fixture = createGoBinaryFixture({ format });
|
||||
if (format === "pe") {
|
||||
fixture.bytes.writeUInt16LE(2, 134);
|
||||
fixture.bytes.copy(fixture.bytes, 304, 264, 304);
|
||||
fixture.bytes.writeUInt32LE(512, 324);
|
||||
} else {
|
||||
fixture.bytes.writeUInt32LE(2, 16);
|
||||
fixture.bytes.writeUInt32LE(224, 20);
|
||||
fixture.bytes.writeUInt32LE(0x19, 184);
|
||||
fixture.bytes.writeUInt32LE(72, 188);
|
||||
fixture.bytes.write("__ALIAS", 192);
|
||||
fixture.bytes.writeBigUInt64LE(0x10000n, 208);
|
||||
fixture.bytes.writeBigUInt64LE(4096n, 216);
|
||||
fixture.bytes.writeBigUInt64LE(16n, 224);
|
||||
fixture.bytes.writeBigUInt64LE(4080n, 232);
|
||||
}
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a contradictory mapping that overlaps only inline string bytes", () => {
|
||||
const fixture = createGoBinaryFixture({ format: "pe" });
|
||||
fixture.bytes.writeUInt16LE(2, 134);
|
||||
fixture.bytes.copy(fixture.bytes, 304, 264, 304);
|
||||
fixture.bytes.writeUInt32LE(16, 312);
|
||||
fixture.bytes.writeUInt32LE(fixture.moduleOffset + 32, 316);
|
||||
fixture.bytes.writeUInt32LE(16, 320);
|
||||
fixture.bytes.writeUInt32LE(512, 324);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
});
|
||||
|
||||
it("ignores the endian flag for pointer-free strings in a big-endian image", () => {
|
||||
const fixture = createGoBinaryFixture({ byteOrder: "big" });
|
||||
fixture.bytes[fixture.headerOffset + 15] = 2;
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.go_version).toBe(
|
||||
"go1.26.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes unsupported containers from malformed recognized images", () => {
|
||||
expect(() =>
|
||||
readGoBinaryImage(Buffer.from([0xca, 0xfe, 0xba, 0xbe])),
|
||||
).toThrowError(/universal|fat/i);
|
||||
expect(() => readGoBinaryImage(Buffer.from("\x7fELF"))).toThrowError(
|
||||
/truncated/i,
|
||||
);
|
||||
const pe = createGoBinaryFixture({ format: "pe" });
|
||||
pe.bytes.writeUInt32LE(0xfffffff0, 60);
|
||||
expect(() => readGoBinaryImage(pe.bytes)).toThrowError(/range|truncated/i);
|
||||
});
|
||||
|
||||
it("rejects ELF header tables overlapping the native header", () => {
|
||||
const fixture = createGoBinaryFixture({ sectionless: true });
|
||||
fixture.bytes.writeBigUInt64LE(0n, 32);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/header|overlap/i,
|
||||
);
|
||||
const declaredSize = createGoBinaryFixture();
|
||||
declaredSize.bytes.writeUInt16LE(8192, 52);
|
||||
expect(() => readGoBinaryImage(declaredSize.bytes)).toThrowError(
|
||||
/truncated|range/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects contradictory aliases when a metadata address maps to different file bytes", () => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.writeUInt16LE(2, 56);
|
||||
fixture.bytes.writeUInt32LE(1, 184);
|
||||
fixture.bytes.writeUInt32LE(6, 188);
|
||||
fixture.bytes.writeBigUInt64LE(16n, 192);
|
||||
fixture.bytes.writeBigUInt64LE(0x10000n, 200);
|
||||
fixture.bytes.writeBigUInt64LE(4080n, 216);
|
||||
fixture.bytes.writeBigUInt64LE(4080n, 224);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
});
|
||||
|
||||
it.each(["pe", "elf"] as const)(
|
||||
"ignores a marker extending past the selected %s data boundary",
|
||||
(format) => {
|
||||
const fixture = createGoBinaryFixture({ format, omitBuildInfo: true });
|
||||
if (format === "pe") fixture.bytes.writeUInt32LE(2040, 264 + 8);
|
||||
else fixture.bytes.writeBigUInt64LE(2040n, 256 + 64 * 2 + 32);
|
||||
Buffer.from("ff20476f206275696c64696e663a", "hex").copy(
|
||||
fixture.bytes,
|
||||
3056,
|
||||
);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0n, 3n])(
|
||||
"rejects reserved extended ELF section counts containing %s",
|
||||
(count) => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.writeUInt16LE(0, 60);
|
||||
fixture.bytes.writeBigUInt64LE(count, 256 + 32);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/extended|section count/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["names", "programs"] as const)(
|
||||
"rejects extended ELF %s values below their reserved ranges",
|
||||
(field) => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.writeUInt16LE(0xffff, field === "names" ? 62 : 56);
|
||||
fixture.bytes.writeUInt32LE(1, 256 + (field === "names" ? 40 : 44));
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/extended/i);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires SHT_NULL for the initial section carrying extended ELF numbering", () => {
|
||||
const bytes = extendedElfFixture();
|
||||
bytes.writeUInt32LE(1, 4096 + 4);
|
||||
expect(() => readGoBinaryImage(bytes)).toThrowError(
|
||||
/initial|section zero|SHT_NULL/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a valid 65,536-entry ELF section table with an extended name-table index", () => {
|
||||
const bytes = extendedElfFixture();
|
||||
expect(readGoBinaryImage(bytes).build_info).toMatchObject({
|
||||
go_version: "go1.26.0",
|
||||
module_text: GO_MODULE_TEXT,
|
||||
});
|
||||
});
|
||||
|
||||
const extendedElfFixture = (): Buffer => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
const tableOffset = 4096;
|
||||
const count = 65536;
|
||||
const bytes = Buffer.alloc(tableOffset + count * 64);
|
||||
fixture.bytes.copy(bytes);
|
||||
bytes.writeBigUInt64LE(BigInt(tableOffset), 40);
|
||||
bytes.writeUInt16LE(0, 60);
|
||||
bytes.writeUInt16LE(0xffff, 62);
|
||||
bytes.writeBigUInt64LE(BigInt(count), tableOffset + 32);
|
||||
bytes.writeUInt32LE(count - 1, tableOffset + 40);
|
||||
fixture.bytes.subarray(384, 448).copy(bytes, tableOffset + 64);
|
||||
fixture.bytes.subarray(320, 384).copy(bytes, tableOffset + (count - 1) * 64);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const legacyLayouts = [
|
||||
["elf", 32, "little"],
|
||||
["elf", 32, "big"],
|
||||
["elf", 64, "little"],
|
||||
["elf", 64, "big"],
|
||||
["pe", 32, "little"],
|
||||
["pe", 64, "little"],
|
||||
["macho", 32, "little"],
|
||||
["macho", 32, "big"],
|
||||
["macho", 64, "little"],
|
||||
["macho", 64, "big"],
|
||||
] as const;
|
||||
|
||||
it.each(legacyLayouts)(
|
||||
"reads moduleless legacy %s %d-bit %s metadata with a zero-filled module header",
|
||||
(format, bits, byteOrder) => {
|
||||
const fixture = zeroFilledModuleFixture(format, bits, byteOrder);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toMatchObject({
|
||||
encoding: "pointer",
|
||||
go_version: "go1.17",
|
||||
module_text: "",
|
||||
module_bytes_base64: "",
|
||||
module_location: { offset: fixture.moduleField, bytes: 0 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"recognizes a module header in a %s segment containing only zero-filled memory",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little", true);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info).toMatchObject({
|
||||
go_version: "go1.17",
|
||||
module_text: "",
|
||||
module_location: { offset: fixture.moduleField, bytes: 0 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"keeps unmapped and partial zero-filled %s module headers invalid",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little");
|
||||
fixture.bytes.writeBigUInt64LE(
|
||||
fixture.moduleAddress + 8n,
|
||||
fixture.moduleField,
|
||||
);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/mapped|range/i,
|
||||
);
|
||||
fixture.bytes.writeBigUInt64LE(0x30000n, fixture.moduleField);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/mapped|range/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"keeps compiler headers in zero-filled %s memory invalid",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little");
|
||||
fixture.bytes.writeBigUInt64LE(
|
||||
fixture.moduleAddress,
|
||||
fixture.headerOffset + 16,
|
||||
);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/mapped|range/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"keeps nonempty string contents in zero-filled %s memory invalid",
|
||||
(format) => {
|
||||
const version = zeroFilledModuleFixture(format, 64, "little");
|
||||
version.bytes.writeBigUInt64LE(version.moduleAddress, 1536);
|
||||
expect(() => readGoBinaryImage(version.bytes)).toThrowError(
|
||||
/mapped|range/i,
|
||||
);
|
||||
const module = zeroFilledModuleFixture(format, 64, "little");
|
||||
module.bytes.writeBigUInt64LE(0x10000n + 1552n, module.moduleField);
|
||||
module.bytes.writeBigUInt64LE(module.moduleAddress, 1552);
|
||||
expect(() => readGoBinaryImage(module.bytes)).toThrowError(/mapped|range/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"rejects zero-filled %s extents overflowing the image address width",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little", true);
|
||||
if (format === "elf")
|
||||
fixture.bytes.writeBigUInt64LE(0xfffffffffffffff8n, 184 + 16);
|
||||
else if (format === "pe")
|
||||
fixture.bytes.writeBigUInt64LE(0xfffffffffffeffffn, 152 + 24);
|
||||
else fixture.bytes.writeBigUInt64LE(0xfffffffffffffff8n, 184 + 24);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(
|
||||
/overflow|addresses/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"ignores unused file offsets for %s memory with no file bytes",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little", true);
|
||||
if (format === "elf")
|
||||
fixture.bytes.writeBigUInt64LE(0xffffffffffffffffn, 184 + 8);
|
||||
else if (format === "pe") fixture.bytes.writeUInt32LE(0xffffffff, 304 + 20);
|
||||
else fixture.bytes.writeBigUInt64LE(0xffffffffffffffffn, 184 + 40);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.go_version).toBe(
|
||||
"go1.17",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat Mach-O __PAGEZERO as a module string header", () => {
|
||||
const fixture = zeroFilledModuleFixture("macho", 64, "little", true);
|
||||
fixture.bytes.fill(0, 184 + 8, 184 + 24);
|
||||
fixture.bytes.write("__PAGEZERO", 184 + 8);
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/mapped|range/i);
|
||||
});
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"refuses file aliases overlapping only part of a zero-filled %s module header",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little");
|
||||
addLegacyMapping(fixture.bytes, format, {
|
||||
address: fixture.moduleAddress + 8n,
|
||||
fileOffset: 512,
|
||||
fileSize: 8,
|
||||
memorySize: 8,
|
||||
});
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"refuses a file alias covering a zero-filled %s module header even when file bytes are zero",
|
||||
(format) => {
|
||||
const fixture = zeroFilledModuleFixture(format, 64, "little");
|
||||
addLegacyMapping(fixture.bytes, format, {
|
||||
address: fixture.moduleAddress,
|
||||
fileOffset: 512,
|
||||
fileSize: 16,
|
||||
memorySize: 16,
|
||||
});
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer PE zero-fill from raw padding beyond a smaller virtual size", () => {
|
||||
const fixture = createGoBinaryFixture({ format: "pe", encoding: "pointer" });
|
||||
fixture.bytes.writeUInt32LE(1024, 264 + 8);
|
||||
expect(readGoBinaryImage(fixture.bytes).build_info?.go_version).toBe(
|
||||
"go1.26.0",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["inline", "pointer"] as const)(
|
||||
"refuses zero-filled aliases overlapping only %s string bytes",
|
||||
(encoding) => {
|
||||
const fixture = createGoBinaryFixture({ format: "pe", encoding });
|
||||
addLegacyMapping(fixture.bytes, "pe", {
|
||||
address: 0x10000n + BigInt(fixture.moduleOffset + 32),
|
||||
fileOffset: 0,
|
||||
fileSize: 0,
|
||||
memorySize: 8,
|
||||
});
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/ambiguous/i);
|
||||
},
|
||||
);
|
||||
|
||||
const zeroFilledModuleFixture = (
|
||||
format: "elf" | "pe" | "macho",
|
||||
bits: 32 | 64,
|
||||
byteOrder: "little" | "big",
|
||||
separateSegment = false,
|
||||
) => {
|
||||
const fixture = createGoBinaryFixture({
|
||||
format,
|
||||
bits,
|
||||
byteOrder,
|
||||
encoding: "pointer",
|
||||
goVersion: "go1.17",
|
||||
});
|
||||
const view = new DataView(
|
||||
fixture.bytes.buffer,
|
||||
fixture.bytes.byteOffset,
|
||||
fixture.bytes.byteLength,
|
||||
);
|
||||
const little = byteOrder === "little";
|
||||
const moduleAddress = separateSegment
|
||||
? 0x20000n
|
||||
: format === "pe"
|
||||
? 0x10c00n
|
||||
: 0x11000n;
|
||||
const moduleField = fixture.headerOffset + 16 + bits / 8;
|
||||
if (bits === 64) view.setBigUint64(moduleField, moduleAddress, little);
|
||||
else view.setUint32(moduleField, Number(moduleAddress), little);
|
||||
if (separateSegment) {
|
||||
addLegacyMapping(
|
||||
fixture.bytes,
|
||||
format,
|
||||
{ address: moduleAddress, fileOffset: 0, fileSize: 0, memorySize: 16 },
|
||||
{ bits, little },
|
||||
);
|
||||
} else if (format === "elf") {
|
||||
if (bits === 64) view.setBigUint64(168, 4112n, little);
|
||||
else view.setUint32(148, 4112, little);
|
||||
} else if (format === "pe") {
|
||||
view.setUint32(152 + (bits === 64 ? 112 : 96) + 8, 2064, little);
|
||||
} else if (bits === 64) {
|
||||
view.setBigUint64(32 + 32, 4112n, little);
|
||||
} else {
|
||||
view.setUint32(28 + 28, 4112, little);
|
||||
}
|
||||
return { ...fixture, moduleAddress, moduleField };
|
||||
};
|
||||
|
||||
const addLegacyMapping = (
|
||||
bytes: Buffer,
|
||||
format: "elf" | "pe" | "macho",
|
||||
{
|
||||
address,
|
||||
fileOffset,
|
||||
fileSize,
|
||||
memorySize,
|
||||
}: {
|
||||
address: bigint;
|
||||
fileOffset: number;
|
||||
fileSize: number;
|
||||
memorySize: number;
|
||||
},
|
||||
{ bits, little }: { bits: 32 | 64; little: boolean } = {
|
||||
bits: 64,
|
||||
little: true,
|
||||
},
|
||||
): void => {
|
||||
const wide = bits === 64;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const u32 = (offset: number, value: number) =>
|
||||
view.setUint32(offset, value, little);
|
||||
const word = (offset: number, value: bigint) => {
|
||||
if (wide) view.setBigUint64(offset, value, little);
|
||||
else u32(offset, Number(value));
|
||||
};
|
||||
if (format === "elf") {
|
||||
view.setUint16(wide ? 56 : 44, 2, little);
|
||||
const entry = 128 + (wide ? 56 : 32);
|
||||
u32(entry, 1);
|
||||
u32(entry + (wide ? 4 : 24), 6);
|
||||
word(entry + (wide ? 8 : 4), BigInt(fileOffset));
|
||||
word(entry + (wide ? 16 : 8), address);
|
||||
word(entry + (wide ? 32 : 16), BigInt(fileSize));
|
||||
word(entry + (wide ? 40 : 20), BigInt(memorySize));
|
||||
} else if (format === "pe") {
|
||||
view.setUint16(134, 2, little);
|
||||
const entry = 152 + (wide ? 112 : 96) + 40;
|
||||
u32(entry + 8, memorySize);
|
||||
u32(entry + 12, Number(address - 0x10000n));
|
||||
u32(entry + 16, fileSize);
|
||||
u32(entry + 20, fileOffset);
|
||||
u32(entry + 36, 0xc0000040);
|
||||
} else {
|
||||
const entry = wide ? 32 + 72 + 80 : 28 + 56 + 68;
|
||||
const size = wide ? 72 : 56;
|
||||
u32(16, 2);
|
||||
u32(20, entry + size - (wide ? 32 : 28));
|
||||
u32(entry, wide ? 0x19 : 1);
|
||||
u32(entry + 4, size);
|
||||
bytes.write("__EXTRA", entry + 8);
|
||||
word(entry + 24, address);
|
||||
word(entry + (wide ? 32 : 28), BigInt(memorySize));
|
||||
word(entry + (wide ? 40 : 32), BigInt(fileOffset));
|
||||
word(entry + (wide ? 48 : 36), BigInt(fileSize));
|
||||
u32(entry + (wide ? 56 : 40), 3);
|
||||
u32(entry + (wide ? 60 : 44), 3);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { mappedFileOffset } from "../../../src/go/GoBinaryContainer.js";
|
||||
|
||||
const selected = { address: 100n, offset: 16, size: 64 };
|
||||
|
||||
it.each([
|
||||
{ address: 132n, offset: 4, size: 4 },
|
||||
{ address: 112n, offset: 4, size: 12 },
|
||||
])(
|
||||
"rejects a contradictory alias overlapping only part of the selected bytes",
|
||||
(alias) => {
|
||||
expect(() => mappedFileOffset([selected, alias], 120n, 24)).toThrowError(
|
||||
/ambiguous/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts overlapping aliases that identify exactly the same file bytes", () => {
|
||||
expect(
|
||||
mappedFileOffset(
|
||||
[selected, { address: 132n, offset: 48, size: 4 }],
|
||||
120n,
|
||||
24,
|
||||
),
|
||||
).toBe(36);
|
||||
});
|
||||
|
||||
it("does not confuse adjacent mappings with overlapping byte spans", () => {
|
||||
const adjacent = { address: 164n, offset: 80, size: 32 };
|
||||
expect(mappedFileOffset([selected, adjacent], 140n, 24)).toBe(56);
|
||||
expect(mappedFileOffset([selected, adjacent], 164n, 0)).toBe(80);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ address: 132n, size: 4n },
|
||||
{ address: 112n, size: 12n },
|
||||
])(
|
||||
"rejects zero-fill aliases overlapping part of file-backed bytes",
|
||||
(zeroFill) => {
|
||||
expect(() =>
|
||||
mappedFileOffset([selected], 120n, 24, [zeroFill]),
|
||||
).toThrowError(/ambiguous/i);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps adjacent zero-filled memory outside the selected file span", () => {
|
||||
expect(
|
||||
mappedFileOffset([selected], 140n, 24, [{ address: 164n, size: 32n }]),
|
||||
).toBe(56);
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { writeFile, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { GoBinaryProvider } from "../../../src/go/GoBinaryProvider.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import {
|
||||
createGoBinaryDiagnosticFixtures,
|
||||
createGoBinaryFixture,
|
||||
} from "../../fixtures/go/image.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("distinguishes unsupported MZ carriers from damaged PE declarations", async () => {
|
||||
const root = await createTestTempDirectory("rea-go-mz-");
|
||||
for (const fixture of createGoBinaryDiagnosticFixtures()) {
|
||||
const path = join(root, `${fixture.name}.exe`);
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await new GoBinaryProvider().inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(
|
||||
fixture.code === "unsupported_target"
|
||||
? AnalysisUnsupportedTargetError
|
||||
: AnalysisInputError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([32, 64] as const)(
|
||||
"rejects advertised PE%d data directories outside the optional header",
|
||||
async (bits) => {
|
||||
const root = await createTestTempDirectory("rea-go-pe-directories-");
|
||||
const fixture = createGoBinaryFixture({ format: "pe", bits });
|
||||
fixture.bytes.writeUInt32LE(1, 152 + (bits === 64 ? 108 : 92));
|
||||
const path = join(root, `truncated-pe${String(bits)}.exe`);
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await new GoBinaryProvider().inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!(result.error instanceof AnalysisInputError)) throw result.error;
|
||||
expect(result.error.issues).toContainEqual(
|
||||
expect.objectContaining({ reason: "invalid_format" }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ signature: [0xce, 0xc5] }, { signature: [0xcc, 0xd8] }])(
|
||||
"rejects high-bit lookalikes of Windows signatures %j",
|
||||
async ({ signature }) => {
|
||||
const root = await createTestTempDirectory("rea-go-pe-signature-");
|
||||
const fixture = createGoBinaryFixture({ format: "pe" });
|
||||
fixture.bytes.set(signature, 128);
|
||||
const path = join(root, "corrupt-signature.exe");
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await new GoBinaryProvider().inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the actual build-info byte guard as a resource failure", async () => {
|
||||
const fixture = createGoBinaryFixture();
|
||||
fixture.bytes.set([0x81, 0x80, 0x40], fixture.headerOffset + 32);
|
||||
const root = await createTestTempDirectory("rea-go-resource-");
|
||||
const path = join(root, "oversized-metadata.elf");
|
||||
await writeFile(path, fixture.bytes);
|
||||
const result = await new GoBinaryProvider().inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok)
|
||||
throw new Error("Oversized embedded metadata must not succeed");
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
if (!(result.error instanceof AnalysisResourceConstraintError))
|
||||
throw result.error;
|
||||
expect(result.error.reason).toContain(path);
|
||||
expect(result.error.reportedLimits).toEqual({
|
||||
boundary: "build-info",
|
||||
maximum_bytes: 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects a symlink without replacing its selected file identity",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-go-symlink-");
|
||||
const selected = join(root, "selected");
|
||||
const original = join(root, "original.elf");
|
||||
await writeFile(original, createGoBinaryFixture().bytes);
|
||||
await symlink(original, selected);
|
||||
const result = await new GoBinaryProvider().inspect({ path: selected });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseGoModuleBytes,
|
||||
parseGoModuleText,
|
||||
} from "../../../src/go/GoModuleText.js";
|
||||
|
||||
describe("Go producer module text", () => {
|
||||
it("preserves local replacements, empty fields and ordered duplicate build settings", () => {
|
||||
const parsed = parseGoModuleText(
|
||||
"path\texample.invalid/app\nmod\texample.invalid/app\t(devel)\t\ndep\texample.invalid/lib\tv1.2.3\th1:bytes\n=>\t../local lib\t\t\nbuild\tGOOS=linux\nbuild\tGOOS=custom\nbuild\t__proto__=kept\n",
|
||||
);
|
||||
expect(parsed).toMatchObject({
|
||||
path: "example.invalid/app",
|
||||
main: { version: "(devel)", sum: "" },
|
||||
dependencies: [
|
||||
{
|
||||
path: "example.invalid/lib",
|
||||
version: "v1.2.3",
|
||||
sum: "h1:bytes",
|
||||
replacement: { path: "../local lib", version: "", sum: "" },
|
||||
},
|
||||
],
|
||||
settings: [
|
||||
{ key: "GOOS", value: "linux" },
|
||||
{ key: "GOOS", value: "custom" },
|
||||
{ key: "__proto__", value: "kept" },
|
||||
],
|
||||
complete: true,
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes Go quoting including byte, octal and Unicode escapes without JSON substitution", () => {
|
||||
const text =
|
||||
String.raw`build\t"key=quoted"="\a\v\xC3\xA9\303\251\u263a\U0001F680"`.replace(
|
||||
"\\t",
|
||||
"\t",
|
||||
) +
|
||||
"\n" +
|
||||
"build\traw=`a\rb`\n";
|
||||
expect(parseGoModuleText(text)).toMatchObject({
|
||||
settings: [
|
||||
{ key: "key=quoted", value: "\x07\x0béé☺🚀" },
|
||||
{ key: "raw", value: "ab" },
|
||||
],
|
||||
complete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unparsed records and malformed escaped bytes while retaining observed fields", () => {
|
||||
const text =
|
||||
'path\tapp\nmod\tapp\t(devel)\nbuild\tbad="\\xff"\nfuture\tdata\nbuild\tGOARCH=amd64';
|
||||
expect(parseGoModuleText(text)).toMatchObject({
|
||||
path: "app",
|
||||
main: { sum: null },
|
||||
settings: [],
|
||||
complete: false,
|
||||
unparsed_lines: [
|
||||
'build\tbad="\\xff"',
|
||||
"future\tdata",
|
||||
"build\tGOARCH=amd64",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach a replacement after a malformed module declaration", () => {
|
||||
const parsed = parseGoModuleText(
|
||||
"dep\tfirst\tv1\ndep\tmalformed\n=>\tlocal\t\t\n",
|
||||
);
|
||||
expect(parsed.dependencies).toEqual([
|
||||
{ path: "first", version: "v1", sum: null, replacement: null },
|
||||
]);
|
||||
expect(parsed.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves duplicate singleton declarations as unknown instead of silently overwriting", () => {
|
||||
expect(
|
||||
parseGoModuleText(
|
||||
"path\tfirst\npath\tsecond\nmod\tfirst\tv1\nmod\tsecond\tv2\n",
|
||||
),
|
||||
).toMatchObject({
|
||||
path: "first",
|
||||
main: { path: "first", version: "v1" },
|
||||
unparsed_lines: ["path\tsecond", "mod\tsecond\tv2"],
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Go producer module bytes", () => {
|
||||
it("retains byte-valued producer settings and parses surrounding UTF-8 records", () => {
|
||||
const setting = Buffer.concat([
|
||||
Buffer.from("build\t-tags="),
|
||||
Buffer.from([0xff]),
|
||||
]);
|
||||
const trailing = Buffer.from([0xfe]);
|
||||
const parsed = parseGoModuleBytes(
|
||||
Buffer.concat([
|
||||
Buffer.from("path\tapp\nmod\tapp\t(devel)\t\nbuild\tGOOS=linux\n"),
|
||||
setting,
|
||||
Buffer.from("\nbuild\tGOARCH=amd64\n"),
|
||||
trailing,
|
||||
]),
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
path: "app",
|
||||
main: { path: "app", version: "(devel)", sum: "", replacement: null },
|
||||
dependencies: [],
|
||||
settings: [
|
||||
{ key: "GOOS", value: "linux" },
|
||||
{ key: "GOARCH", value: "amd64" },
|
||||
],
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: [
|
||||
setting.toString("base64"),
|
||||
trailing.toString("base64"),
|
||||
],
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["mod", "dep", "=>"])(
|
||||
"does not bind a replacement through an undecodable %s record",
|
||||
(record) => {
|
||||
const invalid = Buffer.concat([
|
||||
Buffer.from(`${record}\t`),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from("\tv2\t"),
|
||||
]);
|
||||
expect(
|
||||
parseGoModuleBytes(
|
||||
Buffer.concat([
|
||||
Buffer.from("dep\tfirst\tv1\n"),
|
||||
invalid,
|
||||
Buffer.from("\n=>\tlocal\t\t\n"),
|
||||
]),
|
||||
),
|
||||
).toMatchObject({
|
||||
dependencies: [
|
||||
{ path: "first", version: "v1", sum: null, replacement: null },
|
||||
],
|
||||
unparsed_lines: ["=>\tlocal\t\t"],
|
||||
unparsed_line_bytes_base64: [invalid.toString("base64")],
|
||||
complete: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["build\t-tags=", "future\t"])(
|
||||
"preserves replacement context through an undecodable %s record",
|
||||
(prefix) => {
|
||||
const invalid = Buffer.concat([Buffer.from(prefix), Buffer.from([0xff])]);
|
||||
expect(
|
||||
parseGoModuleBytes(
|
||||
Buffer.concat([
|
||||
Buffer.from("dep\tfirst\tv1\n"),
|
||||
invalid,
|
||||
Buffer.from("\n=>\tlocal\t\t\n"),
|
||||
]),
|
||||
),
|
||||
).toMatchObject({
|
||||
dependencies: [
|
||||
{
|
||||
path: "first",
|
||||
version: "v1",
|
||||
sum: null,
|
||||
replacement: { path: "local", version: "", sum: "" },
|
||||
},
|
||||
],
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: [invalid.toString("base64")],
|
||||
complete: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js";
|
||||
import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { GoBinaryProvider } from "../../../src/go/GoBinaryProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
createCacheProvider,
|
||||
createDeferred,
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
createGoBinaryByteStringFixture,
|
||||
createGoBinaryDiagnosticFixtures,
|
||||
createGoBinaryFixture,
|
||||
createGoBinaryPathFailureFixture,
|
||||
} from "../../fixtures/go/image.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
const goMcpTest = mcpTest.extend<{
|
||||
go: {
|
||||
client: Client;
|
||||
session: ReturnType<typeof createTestBinarySession>;
|
||||
};
|
||||
}>({
|
||||
go: async ({ mcp }, use) => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Go metadata must not start a deep provider");
|
||||
});
|
||||
try {
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }),
|
||||
);
|
||||
await use({ client, session });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
mcpTest(
|
||||
"retains MIPS identity and in-flight Go inspection in the saved session snapshot",
|
||||
async ({ mcp }) => {
|
||||
const root = await createTestTempDirectory("rea-go-mcp-close-");
|
||||
const path = join(root, "application");
|
||||
const snapshotPath = join(root, "analysis.json");
|
||||
const fixture = createGoBinaryFixture({ bits: 32, byteOrder: "big" });
|
||||
fixture.bytes.writeUInt16BE(8, 18); // ELF EM_MIPS.
|
||||
await writeFile(path, fixture.bytes);
|
||||
const entered = createDeferred<void>();
|
||||
const release = createDeferred<void>();
|
||||
const closeStarted = createDeferred<void>();
|
||||
const provider = new GoBinaryProvider();
|
||||
const session = createTestBinarySession(createCacheProvider([]));
|
||||
const service = new GoBinaryService({
|
||||
identity: provider.identity,
|
||||
inspect: async (input, options) => {
|
||||
const result = await provider.inspect(input, options);
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const client = await mcp.connect(
|
||||
createServer({ kind: "session", session }, { goBinary: service }),
|
||||
);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
client.setNotificationHandler("notifications/progress", () => {
|
||||
closeStarted.resolve();
|
||||
});
|
||||
const inspection = client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
await entered.promise;
|
||||
const closing = client.callTool({
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: snapshotPath },
|
||||
_meta: { progressToken: "go-close-race" },
|
||||
});
|
||||
await closeStarted.promise;
|
||||
release.resolve();
|
||||
const inspected = await inspection;
|
||||
expect(inspected.isError, JSON.stringify(inspected.content)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
inspected.structuredContent,
|
||||
);
|
||||
expect(evidence.subject?.architecture).toBe("mips");
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
architecture: "mips",
|
||||
bits: 32,
|
||||
byte_order: "big",
|
||||
});
|
||||
const closed = await closing;
|
||||
expect(closed.isError, JSON.stringify(closed.content)).not.toBe(true);
|
||||
const snapshot = await readAnalysisSnapshot(snapshotPath);
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
expect(snapshot.value.evidence_bundle.records).toContainEqual(evidence);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toBeUndefined();
|
||||
} finally {
|
||||
release.resolve();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) {
|
||||
goMcpTest.skipIf(expectedErrno === "ELOOP" && process.platform === "win32")(
|
||||
`reports native ${expectedErrno} path failure as invalid selected-path input through Go MCP`,
|
||||
async ({ go: { client } }) => {
|
||||
const root = await createTestTempDirectory("rea-go-mcp-path-failure-");
|
||||
const { path, errno } = await createGoBinaryPathFailureFixture(
|
||||
root,
|
||||
expectedErrno,
|
||||
);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
const diagnostic = parseMcpToolError(response);
|
||||
expect(diagnostic.error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "inspect_go_binary",
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(errno),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(diagnostic.error.details).toMatchObject({
|
||||
issues: [{ message: expect.stringContaining(path) }],
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
goMcpTest(
|
||||
"preserves invalid embedded Go text bytes through MCP",
|
||||
async ({ go: { client, session } }) => {
|
||||
const root = await createTestTempDirectory("rea-go-mcp-byte-strings-");
|
||||
const path = join(root, "byte-strings.elf");
|
||||
const fixture = createGoBinaryByteStringFixture();
|
||||
await writeFile(path, fixture.bytes);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
const evidence = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result.build_info).toEqual(
|
||||
fixture.expectedBuildInfo,
|
||||
);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
},
|
||||
);
|
||||
|
||||
goMcpTest(
|
||||
"distinguishes unsupported MZ carriers from malformed PE through MCP",
|
||||
async ({ go: { client } }) => {
|
||||
const root = await createTestTempDirectory("rea-go-mcp-mz-");
|
||||
for (const fixture of createGoBinaryDiagnosticFixtures()) {
|
||||
const path = join(root, `${fixture.name}.exe`);
|
||||
await writeFile(path, fixture.bytes);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response)).toMatchObject({
|
||||
error: {
|
||||
code: fixture.code,
|
||||
details: {
|
||||
...fixture.details,
|
||||
...(fixture.code === "unsupported_target" ? { path } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
goMcpTest(
|
||||
"advertises valid schemas and inspects real file bytes through MCP without an active target",
|
||||
async ({ go: { client, session } }) => {
|
||||
const status = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {},
|
||||
});
|
||||
const availability = expandToolAvailability(
|
||||
toolContract("binary_session").outputSchema.parse(
|
||||
status.structuredContent,
|
||||
).result.tool_availability,
|
||||
);
|
||||
expect(availability).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "inspect_go_binary",
|
||||
available: true,
|
||||
reason: "available",
|
||||
}),
|
||||
);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_go_binary",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("Go metadata must publish both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, { path: "/artifacts/program", execute: true }),
|
||||
).toBe(false);
|
||||
expect(ajv.validate(inputSchema, { path: "/artifacts/\0program" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(advertised.annotations).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
});
|
||||
const root = await createTestTempDirectory("rea-go-mcp-");
|
||||
const path = join(root, "application");
|
||||
const fixture = createGoBinaryFixture({
|
||||
moduleText: "path\texample.com/tool\nnew-record\tfuture\n",
|
||||
});
|
||||
await writeFile(path, fixture.bytes);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_go_binary").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
expect(evidence.confidence).toBe("observed");
|
||||
expect(parsed.normalized_result.build_info?.module).toMatchObject({
|
||||
path: "example.com/tool",
|
||||
complete: false,
|
||||
unparsed_lines: ["new-record\tfuture"],
|
||||
});
|
||||
expect(evidence.limitations.join(" ")).toContain(
|
||||
"could not be parsed completely",
|
||||
);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
const invalid = await client.callTool({
|
||||
name: "inspect_go_binary",
|
||||
arguments: { path: "relative.bin" },
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
expect(parseMcpToolError(invalid)).toMatchObject({
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
details: { issues: [{ path: ["path"], reason: "invalid_format" }] },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -19,13 +19,30 @@ interface ToolSchemas {
|
|||
}
|
||||
|
||||
function schemaErrors(tools: readonly ToolSchemas[]): string[] {
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
// Reuse Ajv's meta-schema validators, then clear each root's reference
|
||||
// namespace, including nested IDs, before validating the next schema.
|
||||
const ajv = new Ajv2020({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
addUsedSchema: false,
|
||||
});
|
||||
return tools.flatMap((tool) =>
|
||||
["inputSchema", "outputSchema"].flatMap((kind) => {
|
||||
const schema =
|
||||
kind === "inputSchema" ? tool.inputSchema : tool.outputSchema;
|
||||
if (schema === undefined || ajv.validateSchema(schema)) return [];
|
||||
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
|
||||
if (schema === undefined) return [];
|
||||
try {
|
||||
if (!ajv.validateSchema(schema))
|
||||
return [`${tool.name}.${kind}: ${ajv.errorsText(ajv.errors)}`];
|
||||
ajv.compile(schema);
|
||||
return [];
|
||||
} catch (cause: unknown) {
|
||||
return [
|
||||
`${tool.name}.${kind}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
];
|
||||
} finally {
|
||||
ajv.removeSchema();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -628,6 +645,69 @@ it("ships nonrecursive input and output schemas in the generated catalog", () =>
|
|||
expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unresolved output-schema references even for uncalled tools", () => {
|
||||
const errors = schemaErrors([
|
||||
{
|
||||
name: "dangling",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { $ref: "#/$defs/missing" },
|
||||
},
|
||||
]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("dangling.outputSchema");
|
||||
});
|
||||
|
||||
it("validates each tool independently when output schema IDs repeat", () => {
|
||||
const outputSchema = { $id: "urn:rea:test:shared-output", type: "string" };
|
||||
const errors = schemaErrors([
|
||||
{ name: "first", inputSchema: { type: "object" }, outputSchema },
|
||||
{
|
||||
name: "second",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { ...outputSchema },
|
||||
},
|
||||
{
|
||||
name: "broken",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { $id: outputSchema.$id, $ref: "#/$defs/missing" },
|
||||
},
|
||||
]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("broken.outputSchema");
|
||||
expect(errors[0]).toContain("missing");
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"rejects references to another tool's nested IDs (earlier compilation fails: %s)",
|
||||
(invalidPrevious) => {
|
||||
const nestedId = "urn:rea:test:nested-output";
|
||||
const errors = schemaErrors([
|
||||
{
|
||||
name: "first",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
$defs: { shared: { $id: nestedId, type: "string" } },
|
||||
...(invalidPrevious ? { pattern: "[" } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "broken",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
$ref: nestedId,
|
||||
$defs: { shared: { type: "number" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(errors).toHaveLength(invalidPrevious ? 2 : 1);
|
||||
const brokenErrors = errors.filter((error) =>
|
||||
error.startsWith("broken.outputSchema:"),
|
||||
);
|
||||
expect(brokenErrors).toHaveLength(1);
|
||||
expect(brokenErrors[0]).toContain(nestedId);
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes recursive references from shared definitions", () => {
|
||||
const diamond = {
|
||||
type: "object",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
import { dosMz } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { lstat, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Actual filesystem paths that fail before Go artifact acquisition begins. */
|
||||
export const createGoBinaryPathFailureFixture = async (
|
||||
root: string,
|
||||
expectedErrno: "ELOOP" | "ENAMETOOLONG",
|
||||
): Promise<{ path: string; errno: string }> => {
|
||||
let path = join(root, "a".repeat(256));
|
||||
if (expectedErrno === "ELOOP") {
|
||||
const loop = join(root, "loop");
|
||||
await symlink(loop, loop, "dir");
|
||||
path = join(loop, "application");
|
||||
}
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(cause instanceof Error) ||
|
||||
!("code" in cause) ||
|
||||
typeof cause.code !== "string"
|
||||
)
|
||||
throw cause;
|
||||
if (
|
||||
(expectedErrno === "ELOOP" || process.platform === "linux") &&
|
||||
cause.code !== expectedErrno
|
||||
)
|
||||
throw new Error(
|
||||
`Path fixture expected ${expectedErrno}, but native lstat returned ${cause.code}.`,
|
||||
{ cause },
|
||||
);
|
||||
return { path, errno: cause.code };
|
||||
}
|
||||
throw new Error(`Expected native lstat to reject path fixture: ${path}`);
|
||||
};
|
||||
|
||||
/** Producer-shaped Go metadata used for portable binary format regressions. */
|
||||
export const GO_MODULE_TEXT =
|
||||
"path\texample.com/tool/cmd/tool\n" +
|
||||
"mod\texample.com/tool\t(devel)\t\n" +
|
||||
"dep\texample.com/dependency\tv1.2.3\th1:sample\n" +
|
||||
"=>\t../dependency\t\t\n" +
|
||||
"build\t-compiler=gc\n" +
|
||||
"build\tGOOS=linux\n" +
|
||||
'build\t-ldflags="-s -w"\n';
|
||||
|
||||
const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex");
|
||||
const MODULE_END = Buffer.from("f932433186182072008242104116d8f2", "hex");
|
||||
|
||||
/** Build an authored, file-backed ELF/PE/Mach-O image without executing code. */
|
||||
export const createGoBinaryFixture = (
|
||||
options: {
|
||||
format?: "elf" | "pe" | "macho";
|
||||
bits?: 32 | 64;
|
||||
byteOrder?: "little" | "big";
|
||||
encoding?: "inline" | "pointer";
|
||||
sectionless?: boolean;
|
||||
goVersion?: string;
|
||||
moduleText?: string;
|
||||
omitBuildInfo?: boolean;
|
||||
} = {},
|
||||
): {
|
||||
bytes: Buffer;
|
||||
headerOffset: number;
|
||||
versionOffset: number;
|
||||
moduleOffset: number;
|
||||
moduleBytes: Buffer;
|
||||
} => {
|
||||
const format = options.format ?? "elf";
|
||||
const bits = options.bits ?? 64;
|
||||
const little = (options.byteOrder ?? "little") === "little";
|
||||
const encoding = options.encoding ?? "inline";
|
||||
const bytes = Buffer.alloc(4096);
|
||||
const headerOffset = 1024;
|
||||
const base = 0x10000;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const u16 = (offset: number, value: number) =>
|
||||
view.setUint16(offset, value, little);
|
||||
const u32 = (offset: number, value: number) =>
|
||||
view.setUint32(offset, value, little);
|
||||
const word = (offset: number, value: number) => {
|
||||
if (bits === 64) view.setBigUint64(offset, BigInt(value), little);
|
||||
else u32(offset, value);
|
||||
};
|
||||
writeFixtureContainer(format, options.sectionless ?? false, {
|
||||
bytes,
|
||||
bits,
|
||||
little,
|
||||
base,
|
||||
headerOffset,
|
||||
u16,
|
||||
u32,
|
||||
word,
|
||||
});
|
||||
const version = Buffer.from(options.goVersion ?? "go1.26.0");
|
||||
const moduleBytes = Buffer.concat([
|
||||
MODULE_START,
|
||||
Buffer.from(options.moduleText ?? GO_MODULE_TEXT),
|
||||
MODULE_END,
|
||||
]);
|
||||
let versionOffset: number;
|
||||
let moduleOffset: number;
|
||||
if (encoding === "inline") {
|
||||
const versionLength = encodeVarint(version.length);
|
||||
versionOffset = headerOffset + 32 + versionLength.length;
|
||||
const moduleLength = encodeVarint(moduleBytes.length);
|
||||
moduleOffset = versionOffset + version.length + moduleLength.length;
|
||||
versionLength.copy(bytes, headerOffset + 32);
|
||||
version.copy(bytes, versionOffset);
|
||||
moduleLength.copy(bytes, versionOffset + version.length);
|
||||
moduleBytes.copy(bytes, moduleOffset);
|
||||
} else {
|
||||
const width = bits / 8;
|
||||
word(headerOffset + 16, base + 1536);
|
||||
word(headerOffset + 16 + width, base + 1536 + width * 2);
|
||||
versionOffset = 2048;
|
||||
moduleOffset = 2304;
|
||||
word(1536, base + versionOffset);
|
||||
word(1536 + width, version.length);
|
||||
word(1536 + width * 2, base + moduleOffset);
|
||||
word(1536 + width * 3, moduleBytes.length);
|
||||
version.copy(bytes, versionOffset);
|
||||
moduleBytes.copy(bytes, moduleOffset);
|
||||
}
|
||||
if (!options.omitBuildInfo) {
|
||||
Buffer.from("ff20476f206275696c64696e663a", "hex").copy(
|
||||
bytes,
|
||||
headerOffset,
|
||||
);
|
||||
bytes[headerOffset + 14] = bits / 8;
|
||||
bytes[headerOffset + 15] =
|
||||
(little ? 0 : 1) | (encoding === "inline" ? 2 : 0);
|
||||
}
|
||||
return { bytes, headerOffset, versionOffset, moduleOffset, moduleBytes };
|
||||
};
|
||||
|
||||
/** Embedded invalid UTF-8 alongside valid records, with exact independent byte expectations. */
|
||||
export const createGoBinaryByteStringFixture = () => {
|
||||
const version = Buffer.from("xgo1.26.0");
|
||||
const invalidLine = Buffer.from("build\t-tags=x");
|
||||
const fixture = createGoBinaryFixture({
|
||||
goVersion: version.toString("utf8"),
|
||||
moduleText:
|
||||
"path\texample.com/tool/cmd/tool\n" +
|
||||
"mod\texample.com/tool\t(devel)\t\n" +
|
||||
"build\tGOARCH=amd64\n" +
|
||||
`${invalidLine.toString("utf8")}\n`,
|
||||
});
|
||||
version[0] = 0xff;
|
||||
version.copy(fixture.bytes, fixture.versionOffset);
|
||||
const lineOffset = fixture.moduleBytes.indexOf(invalidLine);
|
||||
fixture.moduleBytes[lineOffset + invalidLine.length - 1] = 0xff;
|
||||
invalidLine[invalidLine.length - 1] = 0xff;
|
||||
fixture.moduleBytes.copy(fixture.bytes, fixture.moduleOffset);
|
||||
return {
|
||||
bytes: fixture.bytes,
|
||||
expectedBuildInfo: {
|
||||
header_offset: fixture.headerOffset,
|
||||
encoding: "inline",
|
||||
go_version: null,
|
||||
go_version_bytes_base64: version.toString("base64"),
|
||||
module_text: null,
|
||||
module_bytes_base64: fixture.moduleBytes.toString("base64"),
|
||||
version_location: {
|
||||
offset: fixture.versionOffset,
|
||||
bytes: version.length,
|
||||
},
|
||||
module_location: {
|
||||
offset: fixture.moduleOffset,
|
||||
bytes: fixture.moduleBytes.length,
|
||||
},
|
||||
module: {
|
||||
path: "example.com/tool/cmd/tool",
|
||||
main: {
|
||||
path: "example.com/tool",
|
||||
version: "(devel)",
|
||||
sum: "",
|
||||
replacement: null,
|
||||
},
|
||||
dependencies: [],
|
||||
settings: [{ key: "GOARCH", value: "amd64" }],
|
||||
unparsed_lines: [],
|
||||
unparsed_line_bytes_base64: [invalidLine.toString("base64")],
|
||||
complete: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Unsupported DOS/Windows carriers and damaged PE records for public diagnostics. */
|
||||
export const createGoBinaryDiagnosticFixtures = () => {
|
||||
const unsupported = [
|
||||
{ name: "dos-short", bytes: dosMz(16) },
|
||||
{ name: "dos", bytes: dosMz() },
|
||||
];
|
||||
for (const signature of ["NE", "LE", "LX"]) {
|
||||
const bytes = dosMz();
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt32LE(64, 60);
|
||||
bytes.write(signature, 64, "ascii");
|
||||
unsupported.push({ name: signature.toLowerCase(), bytes });
|
||||
}
|
||||
const damagedPe = createGoBinaryFixture({ format: "pe" }).bytes;
|
||||
damagedPe.write("NO", 128, "ascii");
|
||||
const absentDirectories = createGoBinaryFixture({ format: "pe" }).bytes;
|
||||
absentDirectories.writeUInt32LE(1, 152 + 108);
|
||||
const shortOptionalHeaders = [0, 1].map((size) => {
|
||||
const bytes = createGoBinaryFixture({ format: "pe" }).bytes;
|
||||
bytes.writeUInt16LE(size, 148);
|
||||
// Bytes following the declared header must not supply its magic field.
|
||||
bytes.fill(0xff, 152 + size, 154);
|
||||
return { name: `optional-header-${String(size)}-bytes`, bytes };
|
||||
});
|
||||
return [
|
||||
...unsupported.map((fixture) => ({
|
||||
...fixture,
|
||||
code: "unsupported_target" as const,
|
||||
details: { operation: "inspect_go_binary" },
|
||||
})),
|
||||
...[
|
||||
{ name: "damaged-pe", bytes: damagedPe },
|
||||
{ name: "absent-directories", bytes: absentDirectories },
|
||||
...shortOptionalHeaders,
|
||||
].map((fixture) => ({
|
||||
...fixture,
|
||||
code: "invalid_request" as const,
|
||||
details: {
|
||||
operation: "inspect_go_binary",
|
||||
issues: [{ path: ["path"], reason: "invalid_format" }],
|
||||
},
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
const encodeVarint = (input: number): Buffer => {
|
||||
let value = BigInt(input);
|
||||
const bytes: number[] = [];
|
||||
while (value >= 128n) {
|
||||
bytes.push(Number(value & 127n) | 128);
|
||||
value >>= 7n;
|
||||
}
|
||||
bytes.push(Number(value));
|
||||
return Buffer.from(bytes);
|
||||
};
|
||||
|
||||
const writeFixtureContainer = (
|
||||
format: "elf" | "pe" | "macho",
|
||||
sectionless: boolean,
|
||||
context: {
|
||||
bytes: Buffer;
|
||||
bits: 32 | 64;
|
||||
little: boolean;
|
||||
base: number;
|
||||
headerOffset: number;
|
||||
u16: (offset: number, value: number) => void;
|
||||
u32: (offset: number, value: number) => void;
|
||||
word: (offset: number, value: number) => void;
|
||||
},
|
||||
): void => {
|
||||
const { bytes, bits, little, base, headerOffset, u16, u32, word } = context;
|
||||
if (format === "elf") {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 64 ? 2 : 1, little ? 1 : 2, 1]);
|
||||
u16(16, 2);
|
||||
u16(18, bits === 64 ? 62 : 3);
|
||||
u32(20, 1);
|
||||
const wide = bits === 64;
|
||||
word(wide ? 32 : 28, 128);
|
||||
word(wide ? 40 : 32, sectionless ? 0 : 256);
|
||||
u16(wide ? 52 : 40, wide ? 64 : 52);
|
||||
u16(wide ? 54 : 42, wide ? 56 : 32);
|
||||
u16(wide ? 56 : 44, 1);
|
||||
u16(wide ? 58 : 46, wide ? 64 : 40);
|
||||
u16(wide ? 60 : 48, sectionless ? 0 : 3);
|
||||
u16(wide ? 62 : 50, sectionless ? 0 : 1);
|
||||
u32(128, 1);
|
||||
if (wide) {
|
||||
u32(132, 6);
|
||||
word(136, 0);
|
||||
word(144, base);
|
||||
word(160, bytes.length);
|
||||
word(168, bytes.length);
|
||||
} else {
|
||||
word(132, 0);
|
||||
word(136, base);
|
||||
word(144, bytes.length);
|
||||
word(148, bytes.length);
|
||||
u32(152, 6);
|
||||
}
|
||||
if (!sectionless) {
|
||||
const entrySize = wide ? 64 : 40;
|
||||
const strings = Buffer.from("\0.shstrtab\0.go.buildinfo\0");
|
||||
strings.copy(bytes, 768);
|
||||
const names = 256 + entrySize;
|
||||
u32(names, 1);
|
||||
u32(names + 4, 3);
|
||||
word(names + (wide ? 24 : 16), 768);
|
||||
word(names + (wide ? 32 : 20), strings.length);
|
||||
const info = 256 + entrySize * 2;
|
||||
u32(info, 11);
|
||||
u32(info + 4, 1);
|
||||
word(info + (wide ? 16 : 12), base + headerOffset);
|
||||
word(info + (wide ? 24 : 16), headerOffset);
|
||||
word(info + (wide ? 32 : 20), 2048);
|
||||
}
|
||||
} else if (format === "pe") {
|
||||
if (!little) throw new Error("PE fixtures are little endian");
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
u32(60, 128);
|
||||
bytes.write("PE\0\0", 128, "ascii");
|
||||
u16(132, bits === 64 ? 0x8664 : 0x14c);
|
||||
u16(134, 1);
|
||||
const optionalSize = bits === 64 ? 112 : 96;
|
||||
u16(148, optionalSize);
|
||||
u16(152, bits === 64 ? 0x20b : 0x10b);
|
||||
word(152 + (bits === 64 ? 24 : 28), base);
|
||||
const section = 152 + optionalSize;
|
||||
bytes.write(".data", section, "ascii");
|
||||
u32(section + 8, 2048);
|
||||
u32(section + 12, headerOffset);
|
||||
u32(section + 16, 2048);
|
||||
u32(section + 20, headerOffset);
|
||||
u32(section + 36, 0xc0000040);
|
||||
} else {
|
||||
const wide = bits === 64;
|
||||
u32(0, wide ? 0xfeedfacf : 0xfeedface);
|
||||
u32(4, wide ? 0x01000007 : 7);
|
||||
u32(8, 3);
|
||||
u32(12, 2);
|
||||
u32(16, 1);
|
||||
const segmentSize = wide ? 72 : 56;
|
||||
const sectionSize = wide ? 80 : 68;
|
||||
const commandSize = segmentSize + (sectionless ? 0 : sectionSize);
|
||||
u32(20, commandSize);
|
||||
const command = wide ? 32 : 28;
|
||||
u32(command, wide ? 0x19 : 1);
|
||||
u32(command + 4, commandSize);
|
||||
bytes.write("__DATA", command + 8, "ascii");
|
||||
word(command + 24, base);
|
||||
word(command + (wide ? 32 : 28), bytes.length);
|
||||
word(command + (wide ? 40 : 32), 0);
|
||||
word(command + (wide ? 48 : 36), bytes.length);
|
||||
u32(command + (wide ? 56 : 40), 3);
|
||||
u32(command + (wide ? 60 : 44), 3);
|
||||
u32(command + (wide ? 64 : 48), sectionless ? 0 : 1);
|
||||
if (!sectionless) {
|
||||
const section = command + segmentSize;
|
||||
bytes.write("__go_buildinfo", section, "ascii");
|
||||
bytes.write("__DATA", section + 16, "ascii");
|
||||
word(section + 32, base + headerOffset);
|
||||
word(section + (wide ? 40 : 36), 2048);
|
||||
u32(section + (wide ? 48 : 40), headerOffset);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -72,7 +72,10 @@ export async function connectLocalToolsMcp() {
|
|||
const call = async (name: string, arguments_: Record<string, unknown>) => {
|
||||
const definition = definitions.get(name);
|
||||
if (definition !== undefined) compileValidator(definition.inputSchema);
|
||||
const response = await client.callTool({ name, arguments: arguments_ });
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
definition === undefined ? undefined : { toolDefinition: definition },
|
||||
);
|
||||
if (
|
||||
response.structuredContent !== undefined &&
|
||||
definition?.outputSchema !== undefined
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const TEST_PROJECTS = [
|
|||
name: "adapters",
|
||||
include: [
|
||||
"src/*.test.ts",
|
||||
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
|
||||
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
|
||||
],
|
||||
pool: "forks" as const,
|
||||
maxWorkers: MAX_TEST_WORKERS,
|
||||
|
|
|
|||
Loading…
Reference in New Issue