From 645735fc0faa6a40d25ee348728134e3d8952bd9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 9 Nov 2026 04:19:44 -0800 Subject: [PATCH] feat(windows): inspect Shell Link (.lnk) shortcut files (#7859) * feat(windows): inspect Shell Link (.lnk) shortcut files Add inspect_windows_shortcut / inspect-windows-shortcut to read one local Shell Link without resolving or executing the target. The report keeps header flags and FILETIMEs, IDList items, LinkInfo volume and network names, StringData, ExtraData including Tracker droid facts labelled derived, and derived observations such as argument length and a minimized ShowCommand. * fix(windows): tighten Shell Link string reads and coverage - Decode counted StringData over its full CountCharacters payload. - Locate LinkInfo string terminators before charging max_string_bytes and before decoding; an unterminated string is a coverage issue. - Mark an IDList without a TerminalID and an out-of-range LinkInfoHeaderSize as partial coverage. - Report arguments_present and argument_length as null when HasArguments is set but the field could not be read. - Derive target_extension from the joined base path and suffix. - Keep ISO dates for FILETIME and UUIDv1 timestamps before 1970. - Retain the completed file snapshot on parse failures. - Reject an empty CLI path before normalization. - Select the shortcut contract from its family in focused tests. - Fixture StringData no longer writes a NUL into the counted payload. * style: format docs index and Shell Link boundary type Apply oxfmt to the two files format:check reports so Static checks passes. * fix(windows): reject network share paths for Shell Link reads inspect_windows_shortcut is advertised as offline, but the Windows path schema admits UNC paths, and lstat on one can contact an SMB server and offer the caller's credentials. Reject UNC, extended UNC, and other host-prefixed device paths in the provider before any filesystem call. Extended drive paths such as \\?\C:\x stay allowed. A mapped network drive cannot be told from a local drive by its path, and the docs say so. * fix(windows): decode Unicode Shell Link file-entry names File-entry items with bit 0x04 of the type byte, such as 0x36, store a UTF-16LE name, but the parser always read it as Latin-1. The read stopped at the first zero byte, so report.exe became r and an IDList-only shortcut reported the wrong target_extension. Read those names as UTF-16LE. * fix(windows): read volume labels by exact Shell Link discriminator VolumeLabelOffset 0x14 selects VolumeLabelOffsetUnicode and is not itself a label offset, and that Unicode field exists for no other value. The parser treated any offset of 0x14 or more as having both, so a Unicode-only label such as "OS" also produced a false ANSI label "O", and an ANSI label stored past 0x14 had its own bytes read as a Unicode offset. Branch on the exact value in parseVolume, the one place that interprets it, and report a VolumeID too short to hold the selected field. The fixture wrote both encodings for a Unicode volume, which hid the false label, so it now writes one. * fix(windows): limit network share rejection to Windows hosts POSIX hosts read repeated leading slashes as one local root, so an accepted selection such as ///tmp/shortcut.lnk is a local file there. Apply the host-prefixed path check only on Windows, and take the platform through the provider constructor so the Windows path forms stay covered on every test host. * fix(windows): compare shortcut icons only against full paths A stored IDList filename or relative path cannot show that an icon at a full path is elsewhere, so the comparison reported a false difference. Report null unless the icon and a recorded target are both full paths, and assert the missing TerminalID issue by code and offset instead of its message. * fix(windows): keep unread ExtraData blocks unknown A walk that stopped before or inside ExtraData still reported the blocks that HasExpString and HasExpIcon expect as absent, though they were never checked for. Report a block as absent only after the TerminalBlock shows the section was read in full. Without one the block stays unknown and coverage stays partial. * fix(native): ignore LNK network provider type unless ValidNetType is set CommonNetworkRelativeLink NetworkProviderType is only meaningful when the ValidNetType flag (0x2) is set. Return null otherwise. * fix(native): accept any ExtraData TerminalSize below 4 as the terminal MS-SHLLINK requires TerminalSize to be less than 4, not exactly 0. Treat 1, 2 and 3 as the terminal block instead of a malformed block. * fix(native): derive shortcut target facts from the stored environment target Include the EnvironmentVariableDataBlock literal target among the target candidates for target_extension and the icon comparison. Variables are not expanded and the target is not opened. * fix(windows-shortcut): keep unexpanded variable paths unresolved in icon comparison A stored environment target such as C:\Users\%USERNAME%\app.exe could be compared as different from C:\Users\Alice\app.exe. Variable-bearing paths are never expanded, so the icon comparison now returns null unless a fixed target matches the icon exactly. * test: drop redundant ok assertion before typed error guard The testing guide asks for the typed error to be thrown before asserting a successful Result value, and says a preceding .ok assertion adds no coverage. * fix(windows): point out-of-range LinkInfo offsets at their field A LinkInfo, VolumeID or CommonNetworkRelativeLink offset that points past its structure was reported at base + offset. With an offset such as 0xffffffff that position exceeds uint32, so the report failed its schema and the service discarded the whole partial result. Report such issues at the offending offset field instead, which keeps every issue offset and coverage.failing_offset inside the file. Cover each LinkInfo offset field in the parser tests and the LocalBasePathOffset case through the stable filesystem service. * fix(windows): require RFC 4122 variant before deriving Tracker UUID facts A version nibble of 1 alone does not identify a version-1 UUID. Also require the clock_seq_hi_and_reserved top bits to be 10, so other GUID layouts keep their raw value and derive no MAC or timestamp. * fix(windows): ignore DeviceNameOffset fields unless ValidDevice is set MS-SHLLINK 2.3.2 says DeviceNameOffset and DeviceNameOffsetUnicode are ignored when the ValidDevice flag is clear. parseNetwork read both regardless, so garbage in those fields became device-name facts or malformed_structure issues. Read the flags once and only read the device name fields when ValidDevice is set. * fix(windows): keep unsupported Tracker headers as raw data Check TrackerDataBlock Length (0x58) and Version (0) before decoding. A block with any other header is retained as raw bytes with a malformed_structure issue, so it no longer yields MAC and timestamp facts under complete coverage. * fix(windows): compare shortcut icon paths by normalized location Collapse "." and ".." segments and repeated separators in the icon comparison as text only, never opening a path. ".." stops at the drive or UNC share root, and \\?\ and \\.\ paths stay verbatim. Join LocalBasePath with CommonPathSuffix as the target and use the bare base only when the suffix is empty, as the network share branch does. Read the selected shortcut with lstat and open only, so a rejected symlink's destination is never resolved. * fix(windows): keep the UNC share in the root after repeated separators normalizePath took the host and the next split part as the UNC root, so \\server\\share\..\app.exe kept an empty share and let .. remove the real share. Drop empty parts before picking the root so paths on different shares stay different. * fix(cli): parse windows shortcut limits as decimals The four inspect-windows-shortcut limit options now go through cliDecimalField like the other inspection commands, so values such as 0x10 are rejected and cliDecimalArguments.test.ts passes. * fix(native): reject linked parents of windows shortcuts A parent directory symlink or junction pointing at a UNC share was followed by the first lstat, since the network check reads only the path text and the symlink rejection covers only the final component. On Windows, lstat each ancestor from the drive root down and reject the path at the first link, before anything follows it. * docs(native): move Windows shortcut guide and add its feature spec Move the Shell Link guide to docs/guides/native/windows-shortcut.md and list it under Native binaries in the site navigation, keeping the /windows-shortcut public route. Update README, translated README, CLI page and authored skill links to the new physical path. Add docs/specs/native/windows-shortcut/ with spec.md, plan.md and tasks.md, as required by docs/development/documentation.md. * fix(native): admit the windows shortcut parent walk On Windows the ancestor link walk ran before the provider's resource admission, so close() could finish while the walk kept calling lstat. Run the walk inside the same admission as the read, and check the caller's signal before each ancestor lstat. * fix(native): walk the effective parents of windows shortcuts The ancestor link walk checked every prefix of the path as written, so C:\alias\..\real\a.lnk was refused when C:\alias is a directory link, although Windows removes alias\.. before opening the file. Walk the ancestors left after removing . and .. components, keeping the caller's spelling, never climbing above an absolute root, and leaving \\?\ paths as written since Windows does not normalize them. * test(native): assert the linked parent as a structured fact The parent link regression matched the phrase "junction, which" in the refusal message, so a wording edit would fail it. Report the linked ancestor as observed.linked_ancestor on the input issue and assert that fact instead of the explanatory text. * test(native): bound the shortcut close gate and release it in finally Race the parent walk lstat acknowledgement against inspection settling with awaitAcknowledgement, then release the gate and join the inspect and close work in finally so a failure reports its cause instead of waiting for the Vitest timeout. * fix(native): walk Windows shortcut parents lazily windowsAncestors built and kept every parent prefix before the first lstat, so a path with thousands of components allocated memory quadratic in its length. It now keeps only the normalized components and yields each parent from a running prefix as the walk reaches it. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../skills/reverse-engineer-anything/SKILL.md | 8 + .../references/native-and-artifacts.md | 14 + README.md | 1 + docs/.vitepress/documentation.ts | 5 + docs/README.md | 1 + docs/getting-started/cli.md | 13 + docs/guides/native/windows-shortcut.md | 94 ++ docs/readme/README_ar.md | 1 + docs/readme/README_de.md | 1 + docs/readme/README_el.md | 1 + docs/readme/README_es.md | 1 + docs/readme/README_fa.md | 1 + docs/readme/README_fr.md | 1 + docs/readme/README_id.md | 1 + docs/readme/README_it.md | 1 + docs/readme/README_ja.md | 1 + docs/readme/README_ko.md | 1 + docs/readme/README_ml.md | 1 + docs/readme/README_pl.md | 1 + docs/readme/README_pt-BR.md | 1 + docs/readme/README_ro.md | 1 + docs/readme/README_ru.md | 1 + docs/readme/README_th.md | 1 + docs/readme/README_tr.md | 1 + docs/readme/README_uk.md | 1 + docs/readme/README_ur-Latn.md | 1 + docs/readme/README_vi.md | 1 + docs/readme/README_zh-TW.md | 1 + docs/readme/README_zh.md | 1 + docs/specs/native/windows-shortcut/plan.md | 38 + docs/specs/native/windows-shortcut/spec.md | 51 + docs/specs/native/windows-shortcut/tasks.md | 11 + src/application/CapabilityInventory.ts | 6 +- .../BinaryDiagnosticsCancellation.test.ts | 10 + .../binaryDiagnostics/WindowsShortcutPort.ts | 20 + .../WindowsShortcutService.test.ts | 161 +++ .../WindowsShortcutService.ts | 133 ++ src/cli/binaryDiagnosticsCommands.ts | 52 + src/cliCommandNames.ts | 1 + src/composition/binaryDiagnostics.ts | 6 + src/composition/capabilityDeclarations.ts | 6 + src/composition/capabilityServices.ts | 2 + .../native/binaryDiagnosticsToolContracts.ts | 19 + src/contracts/toolEffects.ts | 1 + src/domain/investigationProviders.ts | 7 + src/domain/native/windowsShortcut.test.ts | 114 ++ src/domain/native/windowsShortcut.ts | 360 +++++ src/ghidra/GhidraInstallation.fixture.ts | 6 +- src/native/windows/WindowsShortcut.errors.ts | 28 + .../windows/WindowsShortcutParser.test.ts | 742 ++++++++++ src/native/windows/WindowsShortcutParser.ts | 1213 +++++++++++++++++ src/native/windows/WindowsShortcutProvider.ts | 390 ++++++ src/server/createServer.ts | 9 + src/server/registerWindowsShortcutTool.ts | 31 + .../tools/localToolContracts.test.ts | 1 + tests/acceptance/windowsShortcutCli.test.ts | 107 ++ .../filesystem/windowsShortcut.test.ts | 373 +++++ .../mcp/ghidraFailureDiagnostics.test.ts | 3 +- tests/boundary/mcp/windowsShortcutMcp.test.ts | 73 + .../nativeArtifactFinalization.test.ts | 15 + .../binaryDiagnostics/windowsShortcut.ts | 593 ++++++++ .../stableArtifactConsumers.test.ts | 8 + 62 files changed, 4743 insertions(+), 5 deletions(-) create mode 100644 docs/guides/native/windows-shortcut.md create mode 100644 docs/specs/native/windows-shortcut/plan.md create mode 100644 docs/specs/native/windows-shortcut/spec.md create mode 100644 docs/specs/native/windows-shortcut/tasks.md create mode 100644 src/application/binaryDiagnostics/WindowsShortcutPort.ts create mode 100644 src/application/binaryDiagnostics/WindowsShortcutService.test.ts create mode 100644 src/application/binaryDiagnostics/WindowsShortcutService.ts create mode 100644 src/domain/native/windowsShortcut.test.ts create mode 100644 src/domain/native/windowsShortcut.ts create mode 100644 src/native/windows/WindowsShortcut.errors.ts create mode 100644 src/native/windows/WindowsShortcutParser.test.ts create mode 100644 src/native/windows/WindowsShortcutParser.ts create mode 100644 src/native/windows/WindowsShortcutProvider.ts create mode 100644 src/server/registerWindowsShortcutTool.ts create mode 100644 tests/acceptance/windowsShortcutCli.test.ts create mode 100644 tests/boundary/filesystem/windowsShortcut.test.ts create mode 100644 tests/boundary/mcp/windowsShortcutMcp.test.ts create mode 100644 tests/fixtures/binaryDiagnostics/windowsShortcut.ts diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 47b972543e..66cbefbb1a 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -276,6 +276,14 @@ explicit path or endpoint and do not need it. proof the image never imported APIs through another table. Bound snapshots and loaded IAT contents remain unobserved. See the [PE import guide](https://github.com/morluto/rea/blob/main/docs/pe-imports.md). +- Windows Shell Link shortcuts: `inspect_windows_shortcut` with the explicit + local `.lnk` file. This built-in target-free inspection requires no Windows + SDK or COM. Preserve header flags and FILETIMEs, IDList items, LinkInfo + volume and network names, StringData arguments and icon location, ExtraData + signatures, and Tracker droid facts labelled derived. Unknown IDList items + and ExtraData signatures stay raw with SHA-256. The target path is not + resolved or executed. See the + [Windows shortcut guide](https://github.com/morluto/rea/blob/main/docs/guides/native/windows-shortcut.md). - PE TLS callbacks and load configuration: `inspect_pe_load_config` with the explicit local PE32/PE32+ file. This built-in target-free inspection requires no Windows SDK. Preserve callback VAs as strings with their RVA, file offset diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index f7fc17433f..7c422ba9c2 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -149,6 +149,20 @@ table or overlay. Bound snapshots, loaded IAT contents, and export-forward resolution remain unobserved. See [PE import and delay-load tables](https://github.com/morluto/rea/blob/main/docs/pe-imports.md). +## Windows Shell Link shortcuts + +Use `inspect_windows_shortcut` for an explicit local `.lnk` file when the +question concerns the recorded target path, arguments, working directory, icon +location, volume serial, network share names, ExtraData blocks, or Tracker +droid identifiers. No `open_binary`, Windows SDK, COM, or target execution is +required. The CLI equivalent is `inspect-windows-shortcut PATH`. Preserve +artifact SHA-256, FILETIME raw decimals, IDList type bytes, and ExtraData +signatures. Unknown IDList items and ExtraData signatures remain raw with +SHA-256; they are not guessed. Version-1 UUID MAC and timestamp values are +labelled derived. A missing optional section is observed absence of that +structure. See +[Windows Shell Link shortcuts](https://github.com/morluto/rea/blob/main/docs/guides/native/windows-shortcut.md). + ## PE TLS callbacks and load configuration Use `inspect_pe_load_config` for an explicit local PE32/PE32+ image when the diff --git a/README.md b/README.md index 5c894fb7a5..bb014ce0af 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Additional tools and host support depend on the target: | Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](docs/pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](docs/guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](docs/elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](docs/binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and documented crash classes | Built-in static JSON inspection; [Apple IPS](docs/apple-ips.md) | diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index d79ffaaeca..f9a541b7a2 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -48,6 +48,11 @@ export const documentationSections = [ source: "guides/native/recorded-crashes.md", route: "/recorded-crashes", }, + { + text: "Windows Shell Link shortcuts", + source: "guides/native/windows-shortcut.md", + route: "/windows-shortcut", + }, ], }, { diff --git a/docs/README.md b/docs/README.md index 4be7663644..8cdc839916 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ product pages. The VitePress documentation source is this `docs/` tree. - [Go binary build metadata](guides/native/go-binaries.md) - [Offline binary diagnostics](guides/native/binary-diagnostics.md) - [Recorded crash evidence](guides/native/recorded-crashes.md) +- [Windows Shell Link shortcuts](guides/native/windows-shortcut.md) ## JavaScript and Electron diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index dfcd93de79..72c134d95e 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -439,6 +439,19 @@ descriptors, and a pefile-compatible import hash of the standard import table. A missing import directory is observed absence of that table. See [PE import and delay-load tables](../pe-imports.md). +Inspect a Windows Shell Link (`.lnk`) without resolving or executing the +target: + +```bash +rea inspect-windows-shortcut /absolute/path/to/shortcut.lnk --json +``` + +The workflow reports header flags and FILETIMEs, IDList items, LinkInfo volume +and network names, StringData arguments and icon location, ExtraData blocks +including Tracker droid facts labelled derived, and derived observations such +as argument length and a minimized ShowCommand. See +[Windows Shell Link shortcuts](../guides/native/windows-shortcut.md). + Inspect ELF PT_NOTE and SHT_NOTE tables without executing the image: ```bash diff --git a/docs/guides/native/windows-shortcut.md b/docs/guides/native/windows-shortcut.md new file mode 100644 index 0000000000..548ad6421c --- /dev/null +++ b/docs/guides/native/windows-shortcut.md @@ -0,0 +1,94 @@ +# Windows Shell Link shortcuts + +`inspect_windows_shortcut` / `inspect-windows-shortcut` answers whether one +selected local file is a Windows Shell Link (`.lnk`), which target path, +arguments, working directory, and icon location it records, which volume and +network names `LinkInfo` stores, and which ExtraData blocks follow the +strings. It reads the file without resolving or executing the target and does +not require a Windows SDK, COM, or a network. + +```bash +rea inspect-windows-shortcut /absolute/path/to/shortcut.lnk --json +``` + +The CLI resolves relative paths against the caller's working directory. MCP +and the advertised input schema require an absolute filesystem path and +preserve that exact selection: + +```json +{ + "name": "inspect_windows_shortcut", + "arguments": { "path": "/artifacts/shortcut.lnk" } +} +``` + +## Observations + +The inline Evidence result contains the artifact path, SHA-256, and byte +length. `header` is the 76-byte `ShellLinkHeader`: `LinkFlags` and +`FileAttributes` as raw values plus documented names, creation/access/write +FILETIMEs as a raw 64-bit decimal and ISO-8601 UTC when the timestamp is in +range, file size, icon index, `ShowCommand` (`normal`, `maximized`, or +`minimized`), and hotkey modifiers. Undocumented `ShowCommand` values are +decoded as `normal`, as specified. + +`id_list` is present when `HasLinkTargetIDList` is set. Each item keeps its +file offset, size, and type byte. Root-folder, volume, and file-entry items +are decoded when those layouts match documented shell-item headers, including +the 8.3 short name on a file entry. Every other item is `raw` with a SHA-256 +of its payload. Unknown items are not guessed. + +`link_info` reports volume drive type, serial, label, local base path, and +common network relative link names, with Unicode variants when the header +declares them. ANSI strings use a byte-preserving Latin1 projection; the +original system ANSI code page is unobserved. + +`string_data` reports name, relative path, working directory, command-line +arguments, and icon location according to `LinkFlags`, each with encoding, +`CountCharacters`, byte length, and the decoded value. `IsUnicode` selects +UTF-16LE; otherwise the Latin1 projection is used. + +`extra_data` enumerates every block: signature, size, and offset. +`EnvironmentVariableDataBlock` and `IconEnvironmentDataBlock` expose ANSI and +Unicode targets. `TrackerDataBlock` exposes the NetBIOS machine id and droid / +birth-droid GUIDs. Version-1 UUID MAC and timestamp values are labelled +`evidence_kind: "derived"`. `KnownFolderDataBlock` and `SpecialFolderDataBlock` +keep their folder identifiers. `ConsoleFEDataBlock` keeps the code page. +`PropertyStoreDataBlock` and unrecognized signatures stay raw with SHA-256. +Unknown signatures are listed and are not dropped. + +`observations` are derived only from those documented fields: whether +arguments are present, argument character length, target extension, whether +`ShowCommand` is minimized, and whether the icon location differs from the +recorded target path. That comparison is `null` unless the icon and a recorded +target are both full paths: a stored filename, relative path, or `%VAR%` form +cannot show that two locations differ. This workflow does not emit verdicts +about intent. + +A missing optional section is observed absence of that structure. It is not +proof the shortcut never carried that data in another container. + +## Limits and failures + +The selected file is limited to 1 MiB by default (16 MiB maximum). At most 256 +IDList items (4096 maximum) and 64 ExtraData blocks (1024 maximum) are +examined by default. Retained string payload bytes across StringData, +LinkInfo, and ExtraData are limited to 64 KiB by default (1 MiB maximum). +Malformed `HeaderSize` or `LinkCLSID`, and exhausted budgets, fail without +partial success. Truncation after a valid header, ExtraData size overruns, and +odd Unicode lengths return partial coverage with `coverage.failing_offset`. +A LinkInfo offset that points outside its structure is reported at the +offset field itself, so every issue offset lies inside the file. +Coverage is never `complete` when issues are present. + +The selected path must be a regular file. Symbolic links are rejected. +UNC paths and other host-prefixed device paths, such as `\\server\share\a.lnk` +and `\\?\UNC\server\share\a.lnk`, are rejected before any filesystem call. On +Windows, a path whose parent directory is a symbolic link or junction is +rejected before anything follows that link. The check covers the directories +Windows opens after removing `.` and `..`, so `C:\alias\..\real\a.lnk` is +checked at `C:\real` only; a `\\?\` path is checked as written. A mapped +network drive cannot be told apart from a local drive by its path and is not +rejected. +This workflow does not resolve, open, or execute the link target or call COM, +and it does not contact a network for a path on a local volume. diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 03fc1876eb..83746b536b 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -157,6 +157,7 @@ npx rea-agents@latest setup | بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index 8420f64e40..8b9fbeb39d 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -157,6 +157,7 @@ REA benötigt Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) oder 26+ (>=26.12.1) so | Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 6323f054a3..355f5fab9b 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -159,6 +159,7 @@ npx rea-agents@latest setup | Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 0a6b381281..b2118a7e9b 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -157,6 +157,7 @@ REA requiere Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), adem | Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index f2e771f376..3e6d628158 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -149,6 +149,7 @@ REA به Node.js 22.x (>=22.23.4)، 24.x (>=24.22.1) یا 26+ (>=26.12.1)، به | ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 5957986c49..f79bd56482 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -156,6 +156,7 @@ REA nécessite Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), ai | Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index ea4a74eba6..2215cac29d 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -159,6 +159,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal di Linux x64; [panduan EVM offline](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index 3bb34a0778..5d12a05c0d 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -156,6 +156,7 @@ REA richiede Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) o 26+ (>=26.12.1), oltre | Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale su Linux x64; [guida EVM offline](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 8e9212d721..9edfe7b689 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -157,6 +157,7 @@ REA には Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)、または 26+ (>= | オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index a0f97601cc..7e19cb4e18 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -159,6 +159,7 @@ REA에는 Node.js 22.x(>=22.23.4), 24.x(>=24.22.1) 또는 26+ (>=26.12.1)와 npm | Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index 2a28b39152..ce82e42de6 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -159,6 +159,7 @@ REA-യ്ക്ക് Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), അല്ല | Rust binaries | ഉൾച്ചേർത്ത cargo-auditable crate പേരുകൾ, versions, sources, dependency edges | Built-in static ELF/PE/thin Mach-O പരിശോധന; [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index ecb50bfc88..2842f834a5 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -157,6 +157,7 @@ REA wymaga Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) lub 26+ (>=26.12.1) oraz n | Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index ee64a010ca..aa2a920d4d 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -157,6 +157,7 @@ O REA requer Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) ou 26+ (>=26.12.1), alé | Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index d7020d75e7..16dc1218f0 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -199,6 +199,7 @@ Uneltele suplimentare și platformele acceptate depind de ținta analizată: | Fișiere binare Rust | Nume, versiuni, surse și relații de dependență ale pachetelor crate încorporate prin cargo-auditable | Inspectare statică integrată a fișierelor ELF/PE/Mach-O cu o singură arhitectură; [binare Rust](../rust-binaries.md) | | Certificate PE | Intrări Authenticode WIN_CERTIFICATE și câmpuri de identitate ale semnatarilor PKCS#7 și certificatelor X.509 | Inspectare statică integrată a fișierelor PE; [certificate PE](../pe-certificates.md) | | Importuri PE | Numele DLL-urilor importate și încărcate întârziat, ordinale, RVA-uri ale IAT și hash-ul importurilor calculat de pefile | Inspectare statică integrată a fișierelor PE; [importuri PE](../pe-imports.md) | +| Scurtături Windows | Țintă Shell Link, argumente, director de lucru, ExtraData și identificatori Tracker | Inspectare statică integrată a fișierelor LNK; [scurtături Windows](../guides/native/windows-shortcut.md) | | Note ELF | Proprietari și tipuri PT_NOTE/SHT_NOTE, câmpuri GNU build-id și ABI-tag | Inspectare statică integrată a fișierelor ELF; [note ELF](../elf-notes.md) | | Întărire binară | PIE, NX, RELRO, CFG și indicatori de antet pentru ELF, PE și Mach-O | Inspecție statică integrată a antetelor; [întărire binară](../binary-hardening.md) | | Rapoarte Apple IPS de eroare fatală | Metadate .ips, fire de execuție, imagini binare, registre și clase documentate de erori fatale | Inspectare statică integrată a datelor JSON; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 8fbde016ec..e73bfb30fb 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -159,6 +159,7 @@ REA требует Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) или 26+ (>=26.1 | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 7c935a5739..a50db5ca78 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -157,6 +157,7 @@ REA ต้องใช้ Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) หรื | โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 8ea087bd1e..041e95c6c6 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -157,6 +157,7 @@ REA; Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) veya 26+ (>=26.12.1) ve npm gere | Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 666216428a..42bf2655c7 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -157,6 +157,7 @@ REA потребує Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) або 26+ (>=26 | Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../guides/native/binary-diagnostics.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../guides/evm/evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index 5df9abb722..0e25997bfc 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -199,6 +199,7 @@ Mazeed tools aur host support target par depend karte hain: | Rust binaries | Embedded cargo-auditable crate names, versions, sources aur dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries aur PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import aur delay-load DLL names, ordinals, IAT RVAs aur pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id aur ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index 6a16f85386..dff8277e89 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -159,6 +159,7 @@ REA yêu cầu Node.js 22.x (>=22.23.4), 24.x (>=24.22.1) hoặc 26+ (>=26.12.1) | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Linux kernel modules | .modinfo keys, __versions CRCs, this_module presence, and signature trailer | Built-in static ELF inspection; [kernel modules](../kernel-modules.md) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 31e7d44a57..325d73ddd0 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -160,6 +160,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 47a8bbe2ea..e8ec393b51 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -160,6 +160,7 @@ REA 需要 Node.js 22.x(>=22.23.4)、24.x(>=24.22.1)或 26+ (>=26.12.1) | Erlang BEAM | IFF chunks, atoms, exports, and tables | Built-in static BEAM inspection; [Erlang BEAM](../erlang-beam.md) | | PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](../pe-certificates.md) | | PE imports | Import and delay-load DLL names, ordinals, IAT RVAs and pefile import hash | Built-in static PE inspection; [PE imports](../pe-imports.md) | +| Windows shortcuts | Shell Link target, arguments, working dir, ExtraData and Tracker droid facts | Built-in static LNK inspection; [Windows shortcuts](../guides/native/windows-shortcut.md) | | ELF notes | PT_NOTE/SHT_NOTE owners, types, GNU build-id and ABI-tag fields | Built-in static ELF inspection; [ELF notes](../elf-notes.md) | | Binary hardening | PIE, NX, RELRO, CFG and related header flags for ELF, PE and Mach-O | Built-in static header inspection; [binary hardening](../binary-hardening.md) | | Apple IPS crashes | .ips metadata, threads, images, registers and crash classes | Built-in static JSON inspection; [Apple IPS](../apple-ips.md) | diff --git a/docs/specs/native/windows-shortcut/plan.md b/docs/specs/native/windows-shortcut/plan.md new file mode 100644 index 0000000000..0e1858b6a2 --- /dev/null +++ b/docs/specs/native/windows-shortcut/plan.md @@ -0,0 +1,38 @@ +# Implementation plan + +## Boundary and contract + +Follow the existing binary-diagnostics inspectors. `parseWindowsShortcut` in +`src/native/windows/WindowsShortcutParser.ts` owns Shell Link bytes and +coverage; `src/domain/native/windowsShortcut.ts` owns the result schema. +`WindowsShortcutProvider` (`rea-windows-shortcut`, built in) owns path +validation and the stable regular-file read through `readStableArtifact` and +`ArtifactResourceScope`. `WindowsShortcutService` owns Evidence provenance with +`subject.format: "file"`. The CLI command and MCP tool consume that Evidence +and add no interpretation. + +ANSI strings use a byte-preserving Latin1 projection because the original +system code page is not observable from the file. `IsUnicode` selects +UTF-16LE. Missing optional sections are reported as observed absence only. + +## Incremental work and validation + +1. Define the contract, limits and observations; add failing parser fixtures. +2. Implement the parser for the header, IDList, LinkInfo, StringData and + ExtraData with budgets and partial coverage. +3. Add the provider, service, composition, capability declaration and tool + effects; cover cancellation, symlink rejection and finalization cleanup. +4. Add the CLI command and MCP tool; cover acceptance and advertisement. +5. Update the guide, CLI page, README rows and the authored skill; run focused + tests, repository checks and the documentation route build. + +## Effects and compatibility + +The workflow reads one caller-selected local file. It adds no target execution, +injection, mutation, protection recovery or network access, so no maintainer +scope decision is required. Existing tools and `inspect_artifact` routing are +unchanged. + +## References + +- https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/ diff --git a/docs/specs/native/windows-shortcut/spec.md b/docs/specs/native/windows-shortcut/spec.md new file mode 100644 index 0000000000..1c7eb650bd --- /dev/null +++ b/docs/specs/native/windows-shortcut/spec.md @@ -0,0 +1,51 @@ +# Windows Shell Link shortcut inspection + +## Problem and outcome + +REA has no Shell Link reader. An analyst holding a local `.lnk` file cannot ask +which target, arguments, working directory, icon location, volume or network +names, and ExtraData blocks it records without resolving the target, calling +COM or using a Windows host. Add one static inspector that reads a selected +local file and returns those facts as inline Evidence. + +## Requirements + +- Add `inspect_windows_shortcut` (MCP) and `inspect-windows-shortcut` (CLI). + MCP requires an absolute path; the CLI resolves a relative path against the + caller's working directory. +- Accept only a regular file. Reject symbolic links, UNC and other + host-prefixed device paths before any filesystem call, and on Windows a path + whose parent is a symbolic link or junction. +- Validate the 76-byte `ShellLinkHeader` (`HeaderSize` `0x4C` and the Shell + Link CLSID) and decode `LinkFlags`, `FileAttributes`, FILETIMEs, file size, + icon index, `ShowCommand` and hotkey. +- Decode `LinkTargetIDList` items whose layouts match documented shell-item + headers; keep every other item raw with a SHA-256 of its payload. +- Decode `LinkInfo` volume and network names, `StringData` per `LinkFlags`, and + enumerate every ExtraData block. Unknown signatures stay raw with SHA-256. + Tracker version-1 MAC and timestamp values are labelled derived. +- Emit only documented-field observations: argument presence and length, + target extension, minimized `ShowCommand`, and whether the icon location + differs from a recorded full target path (`null` when not comparable). +- Bound file size, IDList items, ExtraData blocks and retained string bytes, + with caller-adjustable limits and documented maximums. Honor cancellation. +- Never open, resolve or execute the link target, call COM or contact a network + for a local-volume path. + +## Acceptance + +Synthesized fixtures cover a hidden-window PowerShell shortcut with an icon +mismatch, network-share `LinkInfo`, Tracker version 1, ANSI strings, truncated +`StringData`, an unknown ExtraData signature and a wrong CLSID. Malformed +headers and exhausted budgets fail without partial success; truncation after a +valid header returns partial coverage with `coverage.failing_offset`, and +coverage is never `complete` when issues are present. CLI and stdio MCP +acceptance, MCP advertisement, filesystem symlink, budget and cancellation +boundaries, stable-artifact consumers and native acquisition finalization are +covered. + +## Out of scope + +Resolving or launching the target, COM `IShellLink` calls, Windows SDK use, +recovering the original system ANSI code page, verdicts about intent, and +routing `.lnk` files through `inspect_artifact`. diff --git a/docs/specs/native/windows-shortcut/tasks.md b/docs/specs/native/windows-shortcut/tasks.md new file mode 100644 index 0000000000..17fd7ce6ad --- /dev/null +++ b/docs/specs/native/windows-shortcut/tasks.md @@ -0,0 +1,11 @@ +# Tasks + +- [x] Define the single-feature scope, contract, limits and plan. +- [x] Parse the header, IDList, LinkInfo, StringData and ExtraData with + synthesized fixtures, budgets and partial coverage. +- [x] Add the provider, service and Evidence provenance; verify symlink, UNC, + budget, cancellation and finalization boundaries. +- [x] Add the CLI command and MCP tool; verify acceptance and advertisement. +- [x] Document the workflow in the native Windows shortcut guide. +- [ ] Verify against real Explorer-written `.lnk` files and non-Latin1 system + ANSI code pages. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c36a664662..a04df038f4 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -622,7 +622,11 @@ const workflowAvailabilityFor = ( }; if (name === "inspect_pe_resources" || name === "inspect_native_exports") return { reason: "available", remediation: null }; - if (name === "inspect_pe_certificates" || name === "inspect_pe_imports") + if ( + name === "inspect_pe_certificates" || + name === "inspect_pe_imports" || + name === "inspect_windows_shortcut" + ) return { reason: "available", remediation: null }; if ( name === "inspect_elf_notes" || diff --git a/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts b/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts index 6c2b0e076a..ff01c25a18 100644 --- a/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts +++ b/src/application/binaryDiagnostics/BinaryDiagnosticsCancellation.test.ts @@ -3,11 +3,13 @@ import { ElfNotesService } from "./ElfNotesService.js"; import { KernelModuleService } from "./KernelModuleService.js"; import { PeCertificatesService } from "./PeCertificatesService.js"; import { PeImportsService } from "./PeImportsService.js"; +import { WindowsShortcutService } from "./WindowsShortcutService.js"; import { ELF_NOTES_PROVIDER, KERNEL_MODULE_PROVIDER, PE_CERTIFICATES_PROVIDER, PE_IMPORTS_PROVIDER, + WINDOWS_SHORTCUT_PROVIDER, } from "../../domain/investigationProviders.js"; const inspect = (): never => { @@ -36,6 +38,14 @@ it.each([ () => new KernelModuleService({ identity: KERNEL_MODULE_PROVIDER, inspect }), ], + [ + "Windows shortcut", + () => + new WindowsShortcutService({ + identity: WINDOWS_SHORTCUT_PROVIDER, + inspect, + }), + ], ] as const)( "preserves an early cancellation reason for %s before validation", async (_label, create) => { diff --git a/src/application/binaryDiagnostics/WindowsShortcutPort.ts b/src/application/binaryDiagnostics/WindowsShortcutPort.ts new file mode 100644 index 0000000000..a1c6b54f51 --- /dev/null +++ b/src/application/binaryDiagnostics/WindowsShortcutPort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + InspectWindowsShortcutInput, + WindowsShortcut, +} from "../../domain/native/windowsShortcut.js"; +import type { Result } from "../../domain/result.js"; + +/** Stable read and Shell Link decoding, independent of a disassembler session. */ +export interface WindowsShortcutPort { + readonly identity: ProviderIdentity; + close?(): Promise; + inspect( + input: InspectWindowsShortcutInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/WindowsShortcutService.test.ts b/src/application/binaryDiagnostics/WindowsShortcutService.test.ts new file mode 100644 index 0000000000..8deb031b33 --- /dev/null +++ b/src/application/binaryDiagnostics/WindowsShortcutService.test.ts @@ -0,0 +1,161 @@ +import { expect, it } from "vitest"; +import { WindowsShortcutService } from "./WindowsShortcutService.js"; +import { WINDOWS_SHORTCUT_PROVIDER } from "../../domain/investigationProviders.js"; +import type { WindowsShortcut } from "../../domain/native/windowsShortcut.js"; +import { ok } from "../../domain/result.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; + +const identity = WINDOWS_SHORTCUT_PROVIDER; + +const report: WindowsShortcut = { + artifact: { + path: "/artifacts/shortcut.lnk", + sha256: "a".repeat(64), + bytes: 128, + }, + header: { + location: { offset: 0, bytes: 76 }, + header_size: 76, + clsid: "00021401-0000-0000-C000-000000000046", + link_flags: { raw: 0, decoded: [] }, + file_attributes: { raw: 0, decoded: [] }, + creation_time: { raw: "0", iso8601: null }, + access_time: { raw: "0", iso8601: null }, + write_time: { raw: "0", iso8601: null }, + file_size: 0, + icon_index: 0, + show_command: { raw: 1, decoded: "normal" }, + hotkey: { + raw: 0, + modifiers: [], + virtual_key: null, + virtual_key_name: null, + }, + }, + id_list: null, + link_info: null, + string_data: { + name: null, + relative_path: null, + working_dir: null, + arguments: null, + icon_location: null, + }, + extra_data: { blocks: [], terminal_offset: null }, + observations: [ + { kind: "arguments_present", evidence_kind: "derived", value: false }, + { kind: "argument_length", evidence_kind: "derived", characters: 0 }, + { kind: "target_extension", evidence_kind: "derived", extension: null }, + { kind: "show_command_minimized", evidence_kind: "derived", value: false }, + { + kind: "icon_location_differs_from_target", + evidence_kind: "derived", + value: null, + }, + ], + coverage: { + status: "complete", + reason: null, + examined_id_list_items: 0, + examined_extra_data_blocks: 0, + failing_offset: null, + }, + issues: [], + limitations: [ + "Rows reproduce the selected Shell Link bytes; they do not resolve, open, or execute the link target.", + ], +}; + +it("rejects a relative path and extra arguments before reading", async () => { + let calls = 0; + const service = new WindowsShortcutService({ + identity, + inspect: () => { + calls += 1; + return Promise.resolve(ok(report)); + }, + }); + const relative = await service.inspect({ path: "shortcut.lnk" }); + const extra = await service.inspect({ + path: report.artifact.path, + extra: true, + }); + expect(relative.ok).toBe(false); + expect(extra.ok).toBe(false); + if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError); + if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError); + expect(calls).toBe(0); +}); + +it("records the selected shortcut as a file subject", async () => { + const result = await new WindowsShortcutService({ + identity, + inspect: () => Promise.resolve(ok(report)), + }).inspect({ path: report.artifact.path }); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + confidence: "observed", + subject: { format: "file" }, + normalized_result: report, + }); +}); + +it.each(["examined_id_list_items", "examined_extra_data_blocks"] as const)( + "rejects contradictory complete %s coverage", + async (field) => { + const result = await new WindowsShortcutService({ + identity, + inspect: () => + Promise.resolve( + ok({ + ...report, + coverage: { ...report.coverage, [field]: 1 }, + }), + ), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); + }, +); + +it("rejects complete coverage that still lists issues", async () => { + const result = await new WindowsShortcutService({ + identity, + inspect: () => + Promise.resolve( + ok({ + ...report, + issues: [ + { + code: "truncated", + offset: 76, + message: "StringData is truncated", + }, + ], + }), + ), + }).inspect({ path: report.artifact.path }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); +}); + +it("discards an observation if the caller cancels while the provider completes", async () => { + const controller = new AbortController(); + const service = new WindowsShortcutService({ + identity, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(report)); + }, + }); + const result = await service.inspect( + { path: report.artifact.path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/binaryDiagnostics/WindowsShortcutService.ts b/src/application/binaryDiagnostics/WindowsShortcutService.ts new file mode 100644 index 0000000000..44dcd2f620 --- /dev/null +++ b/src/application/binaryDiagnostics/WindowsShortcutService.ts @@ -0,0 +1,133 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { WindowsShortcutPort } from "./WindowsShortcutPort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectWindowsShortcutInputSchema, + windowsShortcutSchema, + type InspectWindowsShortcutInput, + type WindowsShortcut, +} from "../../domain/native/windowsShortcut.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_windows_shortcut"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class WindowsShortcutService { + constructor(readonly provider: WindowsShortcutPort) {} + + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectWindowsShortcutInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = windowsShortcutSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport(report.data, input.value) + ) + return err( + new AnalysisOutputError( + OPERATION, + "Windows shortcut provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: locationsOf(value), + }, + ), + options?.signal, + ); + } +} + +const locationsOf = ( + report: WindowsShortcut, +): readonly ( + | { kind: "artifact-path"; path: string } + | { kind: "file-offset-range"; start: number; end: number } +)[] => { + const ranges: { + kind: "file-offset-range"; + start: number; + end: number; + }[] = [ + { + kind: "file-offset-range", + start: report.header.location.offset, + end: report.header.location.offset + report.header.location.bytes, + }, + ]; + if (report.id_list !== null) + ranges.push({ + kind: "file-offset-range", + start: report.id_list.location.offset, + end: report.id_list.location.offset + report.id_list.location.bytes, + }); + if (report.link_info !== null) + ranges.push({ + kind: "file-offset-range", + start: report.link_info.location.offset, + end: report.link_info.location.offset + report.link_info.location.bytes, + }); + return [{ kind: "artifact-path", path: report.artifact.path }, ...ranges]; +}; + +const validReport = ( + report: WindowsShortcut, + input: InspectWindowsShortcutInput, +): boolean => { + if ( + report.artifact.bytes > input.max_file_bytes || + report.coverage.examined_id_list_items > input.max_id_list_items || + report.coverage.examined_extra_data_blocks > input.max_extra_data_blocks || + (report.id_list?.items.length ?? 0) !== + report.coverage.examined_id_list_items || + report.extra_data.blocks.length !== + report.coverage.examined_extra_data_blocks + ) + return false; + const complete = report.coverage.status === "complete"; + if (complete !== (report.issues.length === 0)) return false; + if (complete && report.coverage.failing_offset !== null) return false; + if (!complete && report.coverage.reason === null) return false; + const inside = ({ offset, bytes }: { offset: number; bytes: number }) => + offset <= report.artifact.bytes - bytes; + if (!inside(report.header.location)) return false; + if (report.id_list !== null && !inside(report.id_list.location)) return false; + if (report.link_info !== null && !inside(report.link_info.location)) + return false; + return report.extra_data.blocks.every((block) => + inside({ offset: block.offset, bytes: block.size }), + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index f98564f987..3ca5ff6280 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -18,6 +18,7 @@ import { createPeResourcesService, createPeCertificatesService, createPeImportsService, + createWindowsShortcutService, createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -30,6 +31,7 @@ import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash. import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; import { inspectPeCertificatesInputSchema } from "../domain/native/peCertificates.js"; import { inspectPeImportsInputSchema } from "../domain/native/peImports.js"; +import { inspectWindowsShortcutInputSchema } from "../domain/native/windowsShortcut.js"; import { inspectNativeExportsInputSchema } from "../domain/native/nativeExports.js"; import { inspectElfNotesInputSchema } from "../domain/native/elfNotes.js"; import { inspectAppleIpsInputSchema } from "../domain/native/appleIps.js"; @@ -379,6 +381,56 @@ export const registerBinaryDiagnosticsCommands = ( ); }), }); + cli.command(CLI_COMMANDS.inspectWindowsShortcut, { + description: + "Inspect a Windows Shell Link (.lnk) without resolving or executing the target", + args: z.object({ + path: localPathStringSchema.describe( + "Local Windows Shell Link (.lnk) file", + ), + }), + options: z.object({ + maxFileBytes: cliDecimalField( + inspectWindowsShortcutInputSchema.shape.max_file_bytes, + ), + maxIdListItems: cliDecimalField( + inspectWindowsShortcutInputSchema.shape.max_id_list_items, + ), + maxExtraDataBlocks: cliDecimalField( + inspectWindowsShortcutInputSchema.shape.max_extra_data_blocks, + ), + maxStringBytes: cliDecimalField( + inspectWindowsShortcutInputSchema.shape.max_string_bytes, + ), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const shortcut = createWindowsShortcutService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectWindowsShortcut, + async () => + finishCliEvidenceCommand( + args.path, + () => + shortcut.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_id_list_items: options.maxIdListItems, + max_extra_data_blocks: options.maxExtraDataBlocks, + max_string_bytes: options.maxStringBytes, + }, + { signal }, + ), + shortcut, + "rea-windows-shortcut", + "inspect_windows_shortcut", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); cli.command(CLI_COMMANDS.inspectPeResources, { description: "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 29e947b959..fb66e04616 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -79,6 +79,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectPeResources: "inspect-pe-resources", inspectPeCertificates: "inspect-pe-certificates", inspectPeImports: "inspect-pe-imports", + inspectWindowsShortcut: "inspect-windows-shortcut", inspectNativeExports: "inspect-native-exports", inspectDevicetree: "inspect-devicetree", inspectElfNotes: "inspect-elf-notes", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index cca8f0e917..f8e15a718e 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -14,6 +14,8 @@ import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertif import { PeCertificatesProvider } from "../native/pe/PeCertificatesProvider.js"; import { PeImportsService } from "../application/binaryDiagnostics/PeImportsService.js"; import { PeImportsProvider } from "../native/pe/PeImportsProvider.js"; +import { WindowsShortcutService } from "../application/binaryDiagnostics/WindowsShortcutService.js"; +import { WindowsShortcutProvider } from "../native/windows/WindowsShortcutProvider.js"; import { ElfNotesService } from "../application/binaryDiagnostics/ElfNotesService.js"; import { ElfNotesProvider } from "../native/elf/ElfNotesProvider.js"; import { AppleIpsService } from "../application/binaryDiagnostics/AppleIpsService.js"; @@ -43,6 +45,10 @@ export const createPeCertificatesService = (): PeCertificatesService => export const createPeImportsService = (): PeImportsService => new PeImportsService(new PeImportsProvider()); +/** Portable Shell Link inspection with no COM, engine or target resolution. */ +export const createWindowsShortcutService = (): WindowsShortcutService => + new WindowsShortcutService(new WindowsShortcutProvider()); + /** Portable PE/ELF export tables with no engine, toolchain or target acquisition. */ export const createNativeExportsService = (): NativeExportsService => new NativeExportsService(new NativeExportsProvider()); diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index 80ce1da280..b9a680b609 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -43,6 +43,7 @@ import { PE_LOAD_CONFIG_PROVIDER, PE_RESOURCES_PROVIDER, WEB_SCRIPT_EXPORT_PROVIDER, + WINDOWS_SHORTCUT_PROVIDER, } from "../domain/investigationProviders.js"; import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/SqliteProviderIdentity.js"; import { @@ -442,6 +443,11 @@ export const CAPABILITY_DECLARATIONS = { providerBinding(PE_RESOURCES_PROVIDER, ["inspect_pe_resources"]), ], }, + windowsShortcut: { + providers: [ + providerBinding(WINDOWS_SHORTCUT_PROVIDER, ["inspect_windows_shortcut"]), + ], + }, pythonPyc: { providers: [ providerBinding(PYTHON_PYC_PROVIDER_IDENTITY, { diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 8a3368367f..505f509b29 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -75,6 +75,7 @@ import { createPeImportsService, createNativeExportsService, createPeResourcesService, + createWindowsShortcutService, createRecordedCrashService, createBinaryLayoutService, pwntoolsHostSupported, @@ -358,6 +359,7 @@ const modules = { binaryHardening: service(() => createBinaryHardeningService()), peCertificates: service(() => createPeCertificatesService()), peImports: service(() => createPeImportsService()), + windowsShortcut: service(() => createWindowsShortcutService()), electronFuses: service( ({ options }) => options.electronFuses ?? createElectronFuseService(), ), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 262abfe179..a219e30bf1 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -60,6 +60,10 @@ import { inspectKernelModuleInputSchema, kernelModuleSchema, } from "../../domain/native/kernelModule.js"; +import { + inspectWindowsShortcutInputSchema, + windowsShortcutSchema, +} from "../../domain/native/windowsShortcut.js"; /** Caller-selected delivery of completed binary layout Evidence. */ export const inspectBinaryLayoutRequestSchema = @@ -226,6 +230,21 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ }, ], }, + { + name: "inspect_windows_shortcut", + ...toolContractMetadata("inspect_windows_shortcut"), + kind: "native-provider", + description: + "Inspect one explicit local Windows Shell Link (.lnk) file without resolving or executing the target, using COM, or opening a network path. Returns ShellLinkHeader flags, FILETIMEs, ShowCommand and hotkey; LinkTargetIDList item count with documented root/drive/file-entry names and raw hashes for unknown items; LinkInfo volume serial, label, local base path and network share names; StringData name, relative path, working directory, arguments and icon location; and ExtraData blocks including EnvironmentVariable, IconEnvironment, Tracker droid/MAC facts labelled derived, KnownFolder, SpecialFolder, ConsoleFE code page, and PropertyStore raw hashes. Unknown ExtraData signatures are listed. Derived observations cover argument presence and length, target extension, minimized ShowCommand, and an icon location that differs from the target. Reads a stable regular file; malformed HeaderSize or CLSID, exhausted budgets, and odd Unicode lengths return typed failures or partial coverage with the failing offset, never a complete partial inventory. Portable on supported Node hosts; no Windows SDK or engine installation required.", + inputSchema: inspectWindowsShortcutInputSchema, + outputSchema: evidenceResultOf(windowsShortcutSchema), + examples: [ + { + title: "Inspect a Windows Shell Link shortcut", + input: { path: "/artifacts/shortcut.lnk" }, + }, + ], + }, { name: "inspect_pe_resources", ...toolContractMetadata("inspect_pe_resources"), diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 55cebccab2..a94531d0f8 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -290,6 +290,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_pe_resources: evidence, inspect_pe_certificates: evidence, inspect_pe_imports: evidence, + inspect_windows_shortcut: evidence, inspect_native_exports: evidence, inspect_elf_notes: evidence, inspect_apple_ips: evidence, diff --git a/src/domain/investigationProviders.ts b/src/domain/investigationProviders.ts index b328aebcbf..fdab763a46 100644 --- a/src/domain/investigationProviders.ts +++ b/src/domain/investigationProviders.ts @@ -40,6 +40,13 @@ export const PE_IMPORTS_PROVIDER = Object.freeze({ version: "1", } as const); +/** Provider identity for portable, execution-free Shell Link decoding. */ +export const WINDOWS_SHORTCUT_PROVIDER = Object.freeze({ + id: "rea-windows-shortcut", + name: "REA Windows shortcut parser", + version: "1", +} as const); + /** Provider identity for portable, execution-free ELF note-table decoding. */ export const DEVICETREE_PROVIDER = Object.freeze({ id: "rea-devicetree", diff --git a/src/domain/native/windowsShortcut.test.ts b/src/domain/native/windowsShortcut.test.ts new file mode 100644 index 0000000000..c88f68f02a --- /dev/null +++ b/src/domain/native/windowsShortcut.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from "vitest"; +import { + inspectWindowsShortcutInputSchema, + windowsShortcutSchema, +} from "./windowsShortcut.js"; + +const report = { + artifact: { + path: "/artifacts/shortcut.lnk", + sha256: "a".repeat(64), + bytes: 128, + }, + header: { + location: { offset: 0, bytes: 76 }, + header_size: 76, + clsid: "00021401-0000-0000-C000-000000000046", + link_flags: { raw: 0, decoded: [] }, + file_attributes: { raw: 0, decoded: [] }, + creation_time: { raw: "0", iso8601: null }, + access_time: { raw: "0", iso8601: null }, + write_time: { raw: "0", iso8601: null }, + file_size: 0, + icon_index: 0, + show_command: { raw: 1, decoded: "normal" as const }, + hotkey: { + raw: 0, + modifiers: [] as Array<"shift" | "control" | "alt">, + virtual_key: null, + virtual_key_name: null, + }, + }, + id_list: null, + link_info: null, + string_data: { + name: null, + relative_path: null, + working_dir: null, + arguments: null, + icon_location: null, + }, + extra_data: { blocks: [], terminal_offset: null }, + observations: [ + { + kind: "arguments_present" as const, + evidence_kind: "derived" as const, + value: false, + }, + { + kind: "argument_length" as const, + evidence_kind: "derived" as const, + characters: 0, + }, + { + kind: "target_extension" as const, + evidence_kind: "derived" as const, + extension: null, + }, + { + kind: "show_command_minimized" as const, + evidence_kind: "derived" as const, + value: false, + }, + { + kind: "icon_location_differs_from_target" as const, + evidence_kind: "derived" as const, + value: null, + }, + ], + coverage: { + status: "complete" as const, + reason: null, + examined_id_list_items: 0, + examined_extra_data_blocks: 0, + failing_offset: null, + }, + issues: [], + limitations: [ + "Rows reproduce the selected Shell Link bytes; they do not resolve, open, or execute the link target.", + ], +}; + +it("rejects extra caller arguments and admits an empty shortcut inventory", () => { + expect( + inspectWindowsShortcutInputSchema.safeParse({ + path: "/artifacts/shortcut.lnk", + extra: true, + }).success, + ).toBe(false); + expect(windowsShortcutSchema.parse(report).header.clsid).toBe( + "00021401-0000-0000-C000-000000000046", + ); +}); + +it("admits partial coverage with a failing offset", () => { + expect( + windowsShortcutSchema.parse({ + ...report, + coverage: { + status: "partial", + reason: "StringData is truncated", + examined_id_list_items: 0, + examined_extra_data_blocks: 0, + failing_offset: 76, + }, + issues: [ + { + code: "truncated", + offset: 76, + message: "StringData is truncated", + }, + ], + }).coverage.failing_offset, + ).toBe(76); +}); diff --git a/src/domain/native/windowsShortcut.ts b/src/domain/native/windowsShortcut.ts new file mode 100644 index 0000000000..50aa6032f8 --- /dev/null +++ b/src/domain/native/windowsShortcut.ts @@ -0,0 +1,360 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { fixedTupleSchema } from "../fixedTupleSchema.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const uint16 = z.number().int().min(0).max(0xffff); +const int32 = z.number().int().min(-0x80000000).max(0x7fffffff); +const range = z.strictObject({ offset: uint, bytes: uint }); +const decimalUint64 = z.string().regex(/^(?:0|[1-9][0-9]*)$/u); +const guidSchema = z + .string() + .regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u); +const macSchema = z.string().regex(/^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$/u); + +/** Hard ceiling on the caller-selected input read budget. */ +export const MAX_INSPECT_WINDOWS_SHORTCUT_FILE_BYTES = 16 * 1024 * 1024; +/** Hard ceiling on LinkTargetIDList items examined in one report. */ +export const MAX_INSPECT_WINDOWS_SHORTCUT_ID_LIST_ITEMS = 4096; +/** Hard ceiling on ExtraData blocks examined in one report. */ +export const MAX_INSPECT_WINDOWS_SHORTCUT_EXTRA_DATA_BLOCKS = 1024; +/** Hard ceiling on retained string payload bytes across StringData, LinkInfo, and ExtraData. */ +export const MAX_INSPECT_WINDOWS_SHORTCUT_STRING_BYTES = 1024 * 1024; + +/** Complete bounded inspection of one Windows Shell Link (.lnk) file. */ +export const inspectWindowsShortcutInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a Windows Shell Link (.lnk) file", + ), + max_file_bytes: z + .number() + .int() + .min(76) + .max(MAX_INSPECT_WINDOWS_SHORTCUT_FILE_BYTES) + .default(1048576) + .describe("Maximum stable artifact bytes to read (default 1 MiB)"), + max_id_list_items: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_WINDOWS_SHORTCUT_ID_LIST_ITEMS) + .default(256) + .describe( + "Maximum LinkTargetIDList items to examine; exceeding the budget fails without partial success", + ), + max_extra_data_blocks: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_WINDOWS_SHORTCUT_EXTRA_DATA_BLOCKS) + .default(64) + .describe( + "Maximum ExtraData blocks to examine; exceeding the budget fails without partial success", + ), + max_string_bytes: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_WINDOWS_SHORTCUT_STRING_BYTES) + .default(65536) + .describe( + "Maximum retained string payload bytes across StringData, LinkInfo, and ExtraData (default 64 KiB)", + ), +}); +export type InspectWindowsShortcutInput = z.infer< + typeof inspectWindowsShortcutInputSchema +>; + +const fileTimeSchema = z.strictObject({ + raw: decimalUint64, + iso8601: z.string().nullable(), +}); + +const namedBitsSchema = z.strictObject({ + raw: uint, + decoded: z.array(z.string().min(1)), +}); + +const showCommandSchema = z.strictObject({ + raw: uint, + decoded: z.enum(["normal", "maximized", "minimized"]), +}); + +const hotKeySchema = z.strictObject({ + raw: uint16, + modifiers: z.array(z.enum(["shift", "control", "alt"])), + virtual_key: uint16.nullable(), + virtual_key_name: z.string().min(1).nullable(), +}); + +const stringFieldSchema = z.strictObject({ + encoding: z.enum(["ansi", "unicode"]), + count_characters: uint16, + byte_length: uint, + value: z.string(), + location: range, +}); + +const idListRootSchema = z.strictObject({ + kind: z.literal("root_folder"), + offset: uint, + size: uint, + type_byte: uint, + sort_index: uint, + folder_id: guidSchema, +}); + +const idListVolumeSchema = z.strictObject({ + kind: z.literal("volume"), + offset: uint, + size: uint, + type_byte: uint, + name: z.string(), +}); + +const idListFileSchema = z.strictObject({ + kind: z.literal("file_entry"), + offset: uint, + size: uint, + type_byte: uint, + entry_class: z.enum(["file", "directory"]), + file_size: uint, + short_name: z.string(), +}); + +const idListRawSchema = z.strictObject({ + kind: z.literal("raw"), + offset: uint, + size: uint, + type_byte: uint, + sha256: digestSchema, +}); + +const idListItemSchema = z.discriminatedUnion("kind", [ + idListRootSchema, + idListVolumeSchema, + idListFileSchema, + idListRawSchema, +]); + +const driveTypeSchema = z.strictObject({ + raw: uint, + name: z + .enum([ + "unknown", + "no_root_dir", + "removable", + "fixed", + "remote", + "cdrom", + "ramdisk", + ]) + .nullable(), +}); + +const volumeSchema = z.strictObject({ + drive_type: driveTypeSchema, + serial: z.string().regex(/^[0-9A-F]{8}$/u), + label: z.string().nullable(), + label_unicode: z.string().nullable(), +}); + +const networkLinkSchema = z.strictObject({ + net_name: z.string().nullable(), + net_name_unicode: z.string().nullable(), + device_name: z.string().nullable(), + device_name_unicode: z.string().nullable(), + network_provider_type: uint.nullable(), +}); + +const linkInfoSchema = z.strictObject({ + location: range, + header_size: uint, + flags: z.strictObject({ + raw: uint, + volume_id_and_local_base_path: z.boolean(), + common_network_relative_link_and_path_suffix: z.boolean(), + }), + volume: volumeSchema.nullable(), + local_base_path: z.string().nullable(), + local_base_path_unicode: z.string().nullable(), + common_network_relative_link: networkLinkSchema.nullable(), + common_path_suffix: z.string().nullable(), + common_path_suffix_unicode: z.string().nullable(), +}); + +const uuidV1DerivedSchema = z.strictObject({ + evidence_kind: z.literal("derived"), + version: z.literal(1), + mac: macSchema, + timestamp_iso8601: z.string().nullable(), + timestamp_100ns: decimalUint64, +}); + +const guidPairSchema = fixedTupleSchema([guidSchema, guidSchema]); +const uuidV1PairSchema = fixedTupleSchema([ + uuidV1DerivedSchema.nullable(), + uuidV1DerivedSchema.nullable(), +]); + +const extraDecodedSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("environment_variable"), + target_ansi: z.string(), + target_unicode: z.string(), + }), + z.strictObject({ + kind: z.literal("icon_environment"), + target_ansi: z.string(), + target_unicode: z.string(), + }), + z.strictObject({ + kind: z.literal("tracker"), + machine_id: z.string(), + droid: guidPairSchema, + birth_droid: guidPairSchema, + droid_uuid_v1: uuidV1PairSchema, + birth_droid_uuid_v1: uuidV1PairSchema, + }), + z.strictObject({ + kind: z.literal("known_folder"), + folder_id: guidSchema, + item_offset: uint, + }), + z.strictObject({ + kind: z.literal("special_folder"), + folder_id: uint, + item_offset: uint, + }), + z.strictObject({ + kind: z.literal("console_fe"), + code_page: uint, + }), + z.strictObject({ + kind: z.literal("property_store"), + bytes: uint, + sha256: digestSchema, + }), + z.strictObject({ + kind: z.literal("raw"), + bytes: uint, + sha256: digestSchema, + }), +]); + +const extraDataBlockSchema = z.strictObject({ + offset: uint, + size: uint, + signature: uint, + signature_name: z.string().min(1).nullable(), + decoded: extraDecodedSchema, +}); + +const argumentsPresentObservationSchema = z.strictObject({ + kind: z.literal("arguments_present"), + evidence_kind: z.literal("derived"), + value: z.boolean().nullable(), +}); +const argumentLengthObservationSchema = z.strictObject({ + kind: z.literal("argument_length"), + evidence_kind: z.literal("derived"), + characters: uint.nullable(), +}); +const targetExtensionObservationSchema = z.strictObject({ + kind: z.literal("target_extension"), + evidence_kind: z.literal("derived"), + extension: z.string().nullable(), +}); +const showCommandMinimizedObservationSchema = z.strictObject({ + kind: z.literal("show_command_minimized"), + evidence_kind: z.literal("derived"), + value: z.boolean(), +}); +const iconLocationDiffersObservationSchema = z.strictObject({ + kind: z.literal("icon_location_differs_from_target"), + evidence_kind: z.literal("derived"), + value: z.boolean().nullable(), +}); + +const issueSchema = z.strictObject({ + code: z.enum([ + "truncated", + "block_size_overrun", + "odd_unicode_length", + "malformed_structure", + "missing_extra_data_block", + ]), + offset: uint, + message: z.string().min(1), +}); + +const coverageSchema = z.strictObject({ + status: z.enum(["complete", "partial"]), + reason: z.string().min(1).nullable(), + examined_id_list_items: uint, + examined_extra_data_blocks: uint, + failing_offset: uint.nullable(), +}); + +/** Static Shell Link inventory of one .lnk file, read without resolving the target. */ +export const windowsShortcutSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: digestSchema, + bytes: uint, + }), + header: z.strictObject({ + location: range, + header_size: uint, + clsid: guidSchema, + link_flags: namedBitsSchema, + file_attributes: namedBitsSchema, + creation_time: fileTimeSchema, + access_time: fileTimeSchema, + write_time: fileTimeSchema, + file_size: uint, + icon_index: int32, + show_command: showCommandSchema, + hotkey: hotKeySchema, + }), + id_list: z + .strictObject({ + location: range, + byte_size: uint16, + item_count: uint, + items: z.array(idListItemSchema), + }) + .nullable(), + link_info: linkInfoSchema.nullable(), + string_data: z.strictObject({ + name: stringFieldSchema.nullable(), + relative_path: stringFieldSchema.nullable(), + working_dir: stringFieldSchema.nullable(), + arguments: stringFieldSchema.nullable(), + icon_location: stringFieldSchema.nullable(), + }), + extra_data: z.strictObject({ + blocks: z.array(extraDataBlockSchema), + terminal_offset: uint.nullable(), + }), + observations: fixedTupleSchema([ + argumentsPresentObservationSchema, + argumentLengthObservationSchema, + targetExtensionObservationSchema, + showCommandMinimizedObservationSchema, + iconLocationDiffersObservationSchema, + ]), + coverage: coverageSchema, + issues: z.array(issueSchema), + limitations: z.array(z.string().min(1)), +}); +export type WindowsShortcut = z.infer; +export type WindowsShortcutIdListItem = z.infer; +export type WindowsShortcutExtraDataBlock = z.infer< + typeof extraDataBlockSchema +>; +export type WindowsShortcutIssue = z.infer; +export type WindowsShortcutStringField = z.infer; +export type WindowsShortcutUuidV1 = z.infer; diff --git a/src/ghidra/GhidraInstallation.fixture.ts b/src/ghidra/GhidraInstallation.fixture.ts index 1cb9cbfbe2..ff57aa7fe3 100644 --- a/src/ghidra/GhidraInstallation.fixture.ts +++ b/src/ghidra/GhidraInstallation.fixture.ts @@ -1,6 +1,8 @@ import { ok as javaProbeOk, err } from "../domain/result.js"; -import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; -import type { GhidraJavaProbeFailure } from "./GhidraJavaProbe.js"; +import type { + GhidraJavaObservation, + GhidraJavaProbeFailure, +} from "./GhidraJavaProbe.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; /** Simulated valid installation for profile tests; never starts Ghidra or Java. */ diff --git a/src/native/windows/WindowsShortcut.errors.ts b/src/native/windows/WindowsShortcut.errors.ts new file mode 100644 index 0000000000..53f753841a --- /dev/null +++ b/src/native/windows/WindowsShortcut.errors.ts @@ -0,0 +1,28 @@ +/** Distinguish a file that is not a Shell Link from later structural damage. */ +export class WindowsShortcutFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + ) { + super(message); + this.name = "WindowsShortcutFormatFailure"; + } +} + +/** Caller-selected budgets that bound Shell Link expansion. */ +export type WindowsShortcutSelectedBoundary = + | "id_list_items" + | "extra_data_blocks" + | "string_bytes"; + +/** Resource boundary exceeded while walking a Shell Link file. */ +export class WindowsShortcutResourceFailure extends Error { + constructor( + readonly boundary: WindowsShortcutSelectedBoundary, + readonly maximum: number, + message: string, + ) { + super(message); + this.name = "WindowsShortcutResourceFailure"; + } +} diff --git a/src/native/windows/WindowsShortcutParser.test.ts b/src/native/windows/WindowsShortcutParser.test.ts new file mode 100644 index 0000000000..bbe1831c9b --- /dev/null +++ b/src/native/windows/WindowsShortcutParser.test.ts @@ -0,0 +1,742 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + inspectWindowsShortcutInputSchema, + windowsShortcutSchema, +} from "../../domain/native/windowsShortcut.js"; +import { + ansiShortcut, + LINK_FLAG, + networkShareShortcut, + powershellHiddenIconSpoofShortcut, + TRACKER_MAC, + TRACKER_UUID_TIMESTAMP, + truncatedStringDataShortcut, + unknownExtraDataShortcut, + uuidV1Bytes, + windowsShortcutBytes, + wrongClsidShortcut, +} from "../../../tests/fixtures/binaryDiagnostics/windowsShortcut.js"; +import { parseWindowsShortcut } from "./WindowsShortcutParser.js"; +import { + WindowsShortcutFormatFailure, + WindowsShortcutResourceFailure, +} from "./WindowsShortcut.errors.js"; + +const input = inspectWindowsShortcutInputSchema.parse({ + path: "/artifacts/shortcut.lnk", +}); + +const artifact = (bytes: Buffer) => ({ + path: "/artifacts/shortcut.lnk", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, +}); + +const parse = (bytes: Buffer, overrides: Partial = {}) => + parseWindowsShortcut(bytes, artifact(bytes), { ...input, ...overrides }); + +it("reads a PowerShell hidden-window shortcut with a spoofed icon", async () => { + const bytes = powershellHiddenIconSpoofShortcut(); + const report = await parse(bytes); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); + expect(report.header.show_command).toEqual({ + raw: 7, + decoded: "minimized", + }); + expect(report.header.creation_time.iso8601).toBe("2024-01-15T12:00:00.000Z"); + expect(report.string_data.arguments?.value).toContain("-WindowStyle Hidden"); + expect(report.string_data.icon_location?.value).toBe( + "C:\\Windows\\System32\\shell32.dll", + ); + expect(report.link_info?.local_base_path_unicode).toContain("powershell.exe"); + expect(report.id_list?.items.some((item) => item.kind === "volume")).toBe( + true, + ); + expect(report.id_list?.items.some((item) => item.kind === "file_entry")).toBe( + true, + ); + expect(report.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "arguments_present", + evidence_kind: "derived", + value: true, + }), + expect.objectContaining({ + kind: "show_command_minimized", + evidence_kind: "derived", + value: true, + }), + expect.objectContaining({ + kind: "icon_location_differs_from_target", + evidence_kind: "derived", + value: true, + }), + expect.objectContaining({ + kind: "target_extension", + evidence_kind: "derived", + extension: ".exe", + }), + ]), + ); + expect(report.coverage.status).toBe("complete"); +}); + +it("reads network-share LinkInfo names", async () => { + const report = await parse(networkShareShortcut()); + expect(report.link_info?.common_network_relative_link).toMatchObject({ + net_name_unicode: "\\\\fileserver\\docs", + device_name_unicode: "Z:", + }); + expect(report.coverage.status).toBe("complete"); +}); + +it("reports the network provider type only when ValidNetType is set", async () => { + const build = (networkFlags: number) => + windowsShortcutBytes({ + flags: LINK_FLAG.HasLinkInfo | LINK_FLAG.IsUnicode, + linkInfo: { + netName: "\\\\fileserver\\docs", + deviceName: "Z:", + networkProviderType: 0x00020000, + networkFlags, + commonPathSuffix: "report.docx", + }, + extraData: [], + }); + const valid = await parse(build(0x3)); + expect(valid.link_info?.common_network_relative_link).toMatchObject({ + network_provider_type: 0x00020000, + }); + const ignored = await parse(build(0x1)); + expect(ignored.link_info?.common_network_relative_link).toMatchObject({ + network_provider_type: null, + }); +}); + +it("ignores device names when ValidDevice is clear", async () => { + const bytes = windowsShortcutBytes({ + flags: LINK_FLAG.HasLinkInfo | LINK_FLAG.IsUnicode, + linkInfo: { + netName: "\\\\fileserver\\docs", + deviceName: "Z:", + networkFlags: 0x2, + commonPathSuffix: "report.docx", + }, + extraData: [], + }); + // Point the ignored DeviceNameOffset fields outside the block. + const block = + bytes.indexOf(Buffer.from("\\\\fileserver\\docs\0", "latin1")) - 0x1c; + bytes.writeUInt32LE(0xffff, block + 12); + bytes.writeUInt32LE(0xffff, block + 24); + const report = await parse(bytes); + expect(report.link_info?.common_network_relative_link).toMatchObject({ + net_name: "\\\\fileserver\\docs", + device_name: null, + device_name_unicode: null, + network_provider_type: 0x00020000, + }); + expect(report.issues).toEqual([]); + expect(report.coverage.status).toBe("complete"); +}); + +it("derives Tracker version-1 MAC and timestamp facts", async () => { + const report = await parse( + windowsShortcutBytes({ extraData: [{ kind: "tracker" }] }), + ); + const tracker = report.extra_data.blocks.find( + (block) => block.decoded.kind === "tracker", + ); + expect(tracker?.decoded.kind).toBe("tracker"); + if (tracker?.decoded.kind !== "tracker") + throw new Error("Expected a TrackerDataBlock"); + expect(tracker.decoded.machine_id).toBe("WORKSTATION"); + const derived = tracker.decoded.droid_uuid_v1[1]; + expect(derived).toMatchObject({ + evidence_kind: "derived", + version: 1, + mac: [...TRACKER_MAC] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(":"), + }); + expect(derived?.timestamp_iso8601).toEqual(expect.stringMatching(/Z$/u)); + expect(report.coverage.status).toBe("complete"); +}); + +it("does not derive Tracker facts from non-RFC 4122 variant GUIDs", async () => { + const ncsVariant = uuidV1Bytes(TRACKER_UUID_TIMESTAMP, 2, TRACKER_MAC); + ncsVariant[8] = 0x00; + const valid = uuidV1Bytes(TRACKER_UUID_TIMESTAMP, 1, TRACKER_MAC); + const report = await parse( + windowsShortcutBytes({ + extraData: [{ kind: "tracker", droid: [valid, ncsVariant] }], + }), + ); + const tracker = report.extra_data.blocks.find( + (block) => block.decoded.kind === "tracker", + ); + if (tracker?.decoded.kind !== "tracker") + throw new Error("Expected a TrackerDataBlock"); + expect(tracker.decoded.droid_uuid_v1[0]).not.toBeNull(); + expect(tracker.decoded.droid_uuid_v1[1]).toBeNull(); + expect(tracker.decoded.droid[1]).toEqual(expect.any(String)); +}); + +it.each([ + { length: 0, version: 0 }, + { length: 0x58, version: 1 }, +])( + "keeps a Tracker block with Length $length and Version $version as raw data", + async ({ length, version }) => { + const report = await parse( + windowsShortcutBytes({ + extraData: [{ kind: "tracker", length, version }], + }), + ); + const tracker = report.extra_data.blocks.find( + (block) => block.signature === 0xa0000003, + ); + expect(tracker?.decoded.kind).toBe("raw"); + expect( + report.issues.some((issue) => issue.code === "malformed_structure"), + ).toBe(true); + expect(report.coverage.status).toBe("partial"); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); + }, +); + +it("reads ANSI StringData when IsUnicode is unset", async () => { + const report = await parse(ansiShortcut()); + expect(report.string_data.name?.encoding).toBe("ansi"); + expect(report.header.link_flags.decoded).not.toContain("IsUnicode"); + expect(report.coverage.status).toBe("complete"); +}); + +it("reports truncated StringData as partial coverage with a failing offset", async () => { + const report = await parse(truncatedStringDataShortcut()); + expect(report.coverage.status).toBe("partial"); + expect(report.coverage.failing_offset).toEqual(expect.any(Number)); + expect(report.issues.some((issue) => issue.code === "truncated")).toBe(true); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); +}); + +it.each([12, 16, 20, 24, 28, 32])( + "points an out-of-range LinkInfo offset at its field at byte %i", + async (field) => { + const bytes = windowsShortcutBytes({ + idList: null, + linkInfo: { + localBasePath: "C:\\target.exe", + netName: "\\\\server\\share", + }, + }); + bytes.writeUInt32LE(0xffffffff, 76 + field); + const report = await parse(bytes); + expect(report.coverage.status).toBe("partial"); + expect(report.coverage.failing_offset).toBe(76 + field); + expect(report.issues.every((issue) => issue.offset < bytes.length)).toBe( + true, + ); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); + }, +); + +it("lists an unknown ExtraData signature instead of dropping it", async () => { + const report = await parse(unknownExtraDataShortcut()); + expect(report.extra_data.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + signature: 0xa0ffffff, + signature_name: null, + decoded: expect.objectContaining({ kind: "raw" }), + }), + ]), + ); + expect(report.coverage.status).toBe("complete"); +}); + +it("rejects a Shell Link whose CLSID is not the documented value", async () => { + await expect(parse(wrongClsidShortcut())).rejects.toBeInstanceOf( + WindowsShortcutFormatFailure, + ); +}); + +it("rejects a HeaderSize other than 0x4C", async () => { + await expect( + parse(windowsShortcutBytes({ headerSize: 0x50, extraData: [] })), + ).rejects.toMatchObject({ + name: "WindowsShortcutFormatFailure", + kind: "malformed", + }); +}); + +it("reports an ExtraData size that overruns the file", async () => { + const bytes = windowsShortcutBytes({ extraData: [], terminalBlock: false }); + const overrun = Buffer.concat([ + bytes, + Buffer.from([0x40, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0xa0]), + ]); + const report = await parse(overrun); + expect(report.coverage.status).toBe("partial"); + expect( + report.issues.some((issue) => issue.code === "block_size_overrun"), + ).toBe(true); +}); + +const HAS_EXP_STRING = 0x00000200; +const HAS_EXP_ICON = 0x00004000; + +/** Set extra LinkFlags on an already built shortcut. */ +const withLinkFlags = (bytes: Buffer, added: number): Buffer => { + const flagged = Buffer.from(bytes); + flagged.writeUInt32LE((flagged.readUInt32LE(0x14) | added) >>> 0, 0x14); + return flagged; +}; + +const missingBlockIssues = (report: { issues: readonly { code: string }[] }) => + report.issues.filter((issue) => issue.code === "missing_extra_data_block"); + +it("reports an expected block as absent once the TerminalBlock is read", async () => { + const report = await parse( + withLinkFlags( + windowsShortcutBytes({ extraData: [] }), + HAS_EXP_STRING | HAS_EXP_ICON, + ), + ); + expect(report.extra_data.terminal_offset).toEqual(expect.any(Number)); + expect(missingBlockIssues(report)).toEqual([ + expect.objectContaining({ + message: "HasExpString is set and EnvironmentVariableDataBlock is absent", + }), + expect.objectContaining({ + message: "HasExpIcon is set and IconEnvironmentDataBlock is absent", + }), + ]); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); +}); + +it("keeps an expected block unknown when StringData stops before ExtraData", async () => { + const report = await parse( + withLinkFlags(truncatedStringDataShortcut(), HAS_EXP_STRING), + ); + expect(report.coverage.status).toBe("partial"); + expect(report.extra_data.terminal_offset).toBeNull(); + expect(missingBlockIssues(report)).toEqual([]); +}); + +it("keeps an expected block unknown when an ExtraData block overruns the file", async () => { + const bytes = windowsShortcutBytes({ extraData: [], terminalBlock: false }); + const report = await parse( + withLinkFlags( + Buffer.concat([ + bytes, + Buffer.from([0x40, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0xa0]), + ]), + HAS_EXP_ICON, + ), + ); + expect(report.coverage.status).toBe("partial"); + expect(missingBlockIssues(report)).toEqual([]); +}); + +it("treats any TerminalSize below 4 as the ExtraData terminal", async () => { + const base = windowsShortcutBytes({ extraData: [], terminalBlock: false }); + for (const size of [1, 2, 3]) { + const report = await parse( + Buffer.concat([base, Buffer.from([size, 0x00, 0x00, 0x00])]), + ); + expect(report.extra_data.terminal_offset).toBe(base.length); + expect(report.coverage.status).toBe("complete"); + } +}); + +it("derives target facts from a stored environment target alone", async () => { + const report = await parse( + windowsShortcutBytes({ + flags: LINK_FLAG.IsUnicode | LINK_FLAG.HasIconLocation, + iconLocation: "C:\\Tools\\app.exe", + extraData: [ + { + kind: "environment", + ansi: "C:\\Tools\\app.exe", + unicode: "C:\\Tools\\app.exe", + }, + ], + }), + ); + expect(report.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "target_extension", + extension: ".exe", + }), + expect.objectContaining({ + kind: "icon_location_differs_from_target", + value: false, + }), + ]), + ); +}); + +it("reports partial coverage when an expected block has no ExtraData to read", async () => { + const report = await parse( + withLinkFlags( + windowsShortcutBytes({ extraData: [], terminalBlock: false }), + HAS_EXP_STRING, + ), + ); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toEqual([ + expect.objectContaining({ + code: "truncated", + message: "ExtraData TerminalBlock is absent", + }), + ]); +}); + +it("reports an odd Unicode StringData remainder", async () => { + const header = windowsShortcutBytes({ + flags: 0x84, + idList: null, + linkInfo: null, + extraData: [], + terminalBlock: false, + name: "A", + }); + const truncated = header.subarray(0, header.length - 1); + const report = await parse(truncated); + expect(report.coverage.status).toBe("partial"); + expect( + report.issues.some( + (issue) => + issue.code === "odd_unicode_length" || issue.code === "truncated", + ), + ).toBe(true); +}); + +it("fails closed when the IDList item budget is exhausted", async () => { + await expect( + parse(powershellHiddenIconSpoofShortcut(), { max_id_list_items: 1 }), + ).rejects.toBeInstanceOf(WindowsShortcutResourceFailure); +}); + +it("keeps unknown IDList items as raw hashed blobs", async () => { + const report = await parse( + windowsShortcutBytes({ + idList: [{ kind: "raw", data: Buffer.from([0x99, 0x01, 0x02, 0x03]) }], + extraData: [], + }), + ); + expect(report.id_list?.items).toEqual([ + expect.objectContaining({ + kind: "raw", + type_byte: 0x99, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + }), + ]); +}); + +it("decodes UTF-16LE names on Unicode file-entry items", async () => { + const header = Buffer.alloc(12); + header[0] = 0x36; + const name = Buffer.from("report.exe\0", "utf16le"); + const report = await parse( + windowsShortcutBytes({ + idList: [{ kind: "raw", data: Buffer.concat([header, name]) }], + extraData: [], + }), + ); + expect(report.id_list?.items).toEqual([ + expect.objectContaining({ + kind: "file_entry", + type_byte: 0x36, + entry_class: "file", + short_name: "report.exe", + }), + ]); + expect(report.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "target_extension", + extension: ".exe", + }), + ]), + ); +}); + +it("keeps counted StringData bytes after an embedded NUL", async () => { + const report = await parse(windowsShortcutBytes({ arguments: "a\0-hidden" })); + expect(report.string_data.arguments?.value).toBe("a\0-hidden"); + expect(report.observations).toContainEqual({ + kind: "argument_length", + evidence_kind: "derived", + characters: 9, + }); +}); + +it("reports argument presence as unknown when HasArguments data is unreadable", async () => { + // Cut the file inside COMMAND_LINE_ARGUMENTS so the field cannot be read. + const report = await parse( + windowsShortcutBytes({ + iconLocation: "", + extraData: [], + terminalBlock: false, + omitStringBytes: 10, + }), + ); + expect(report.coverage.status).toBe("partial"); + expect(report.string_data.arguments).toBeNull(); + expect(report.observations).toContainEqual({ + kind: "arguments_present", + evidence_kind: "derived", + value: null, + }); + expect(windowsShortcutSchema.safeParse(report).success).toBe(true); +}); + +it("derives the target extension from the joined base path and suffix", async () => { + const report = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\x", commonPathSuffix: "app.exe" }, + relativePath: "", + idList: null, + }), + ); + expect(report.observations).toContainEqual({ + kind: "target_extension", + evidence_kind: "derived", + extension: ".exe", + }); +}); + +it("reads a Unicode volume label without inventing an ANSI label", async () => { + const report = await parse( + windowsShortcutBytes({ linkInfo: { label: "OS" } }), + ); + expect(report.link_info?.volume).toMatchObject({ + label: null, + label_unicode: "OS", + }); + expect(report.issues).toEqual([]); +}); + +it("does not read a Unicode label offset past an ANSI label offset", async () => { + const report = await parse( + windowsShortcutBytes({ + unicode: false, + linkInfo: { + label: "DATA", + // At the position of VolumeLabelOffsetUnicode, which is absent here. + volumeLabelGap: Buffer.from([0x18, 0, 0, 0, 0, 0, 0, 0]), + }, + }), + ); + expect(report.link_info?.volume).toMatchObject({ + label: "DATA", + label_unicode: null, + }); + expect(report.issues).toEqual([]); +}); + +it("reports a VolumeID that selects a Unicode label it cannot hold", async () => { + const bytes = windowsShortcutBytes({ + idList: null, + linkInfo: { label: "OS" }, + }); + const linkInfo = 76; + const volume = linkInfo + bytes.readUInt32LE(linkInfo + 12); + bytes.writeUInt32LE(16, volume); + const report = await parse(bytes); + expect(report.link_info?.volume).toMatchObject({ + label: null, + label_unicode: null, + }); + expect(report.issues).toContainEqual( + expect.objectContaining({ code: "truncated", offset: volume + 16 }), + ); +}); + +it("keeps readable dates for FILETIME values before 1970", async () => { + // 1950-01-01T00:00:00Z in 100 ns intervals since 1601. + const report = await parse( + windowsShortcutBytes({ creationTime: 110133216000000000n }), + ); + expect(report.header.creation_time.iso8601).toBe("1950-01-01T00:00:00.000Z"); +}); + +it("marks an IDList without a TerminalID as partial", async () => { + const bytes = windowsShortcutBytes({ + idList: [{ kind: "volume", name: "C:\\" }], + }); + // Replace the IDList's TerminalID with a 2-byte empty ItemID of the same size. + const declared = bytes.readUInt16LE(76); + bytes.writeUInt16LE(2, 76 + declared); + const report = await parse(bytes); + expect(report.coverage.status).toBe("partial"); + expect(report.issues).toContainEqual( + expect.objectContaining({ + code: "malformed_structure", + offset: 78 + declared, + }), + ); +}); + +const iconDifference = ( + report: Awaited>, +): boolean | null | undefined => { + const observation = report.observations.find( + ({ kind }) => kind === "icon_location_differs_from_target", + ); + return observation?.kind === "icon_location_differs_from_target" + ? observation.value + : undefined; +}; + +it("leaves the icon comparison unknown when only a filename is stored", async () => { + const report = await parse( + windowsShortcutBytes({ + linkInfo: null, + idList: [ + { kind: "file", shortName: "app.exe", fileSize: 1, classType: 0x32 }, + ], + iconLocation: "C:\\Tools\\app.exe", + }), + ); + expect(iconDifference(report)).toBeNull(); +}); + +it("leaves the icon comparison unknown for a relative target or icon", async () => { + const relativeTarget = await parse( + windowsShortcutBytes({ + linkInfo: null, + relativePath: ".\\app.exe", + iconLocation: "C:\\Tools\\app.exe", + }), + ); + expect(iconDifference(relativeTarget)).toBeNull(); + + const environmentIcon = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Windows\\System32\\cmd.exe" }, + iconLocation: "%SystemRoot%\\System32\\cmd.exe", + }), + ); + expect(iconDifference(environmentIcon)).toBeNull(); +}); + +it("compares an icon with a full target path, ignoring case and separators", async () => { + const same = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Tools\\app.exe" }, + iconLocation: "c:/tools/APP.exe", + }), + ); + expect(iconDifference(same)).toBe(false); + + const different = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Tools\\app.exe" }, + iconLocation: "C:\\Tools\\other.exe", + }), + ); + expect(iconDifference(different)).toBe(true); +}); + +it("compares an icon with a target after collapsing dot segments as text", async () => { + const iconFor = async (localBasePath: string, iconLocation: string) => + iconDifference( + await parse( + windowsShortcutBytes({ linkInfo: { localBasePath }, iconLocation }), + ), + ); + expect(await iconFor("C:\\Tools\\.\\app.exe", "C:\\Tools\\app.exe")).toBe( + false, + ); + expect( + await iconFor("C:\\Tools\\bin\\..\\\\app.exe", "c:/tools//app.exe"), + ).toBe(false); + // `..` stops at the drive root and the UNC share root. + expect(await iconFor("C:\\..\\..\\app.exe", "C:\\app.exe")).toBe(false); + expect( + await iconFor( + "\\\\server\\share\\..\\app.exe", + "\\\\server\\share\\app.exe", + ), + ).toBe(false); + expect( + await iconFor("\\\\server\\share\\..\\app.exe", "\\\\server\\app.exe"), + ).toBe(true); + // A repeated separator after the host still leaves the share in the root. + expect( + await iconFor( + "\\\\server\\\\shareA\\..\\app.exe", + "\\\\server\\\\shareB\\..\\app.exe", + ), + ).toBe(true); + expect( + await iconFor( + "\\\\server\\\\share\\..\\app.exe", + "\\\\server\\share\\app.exe", + ), + ).toBe(false); + expect(await iconFor("C:\\Tools\\..\\app.exe", "C:\\Tools\\app.exe")).toBe( + true, + ); +}); + +it("compares an icon with the joined base path and suffix, not the bare base", async () => { + const report = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Tools", commonPathSuffix: "app.exe" }, + iconLocation: "C:\\Tools", + }), + ); + expect(iconDifference(report)).toBe(true); +}); + +it("leaves the icon comparison unknown when a stored target keeps an unexpanded variable", async () => { + const environmentTarget = { + kind: "environment", + ansi: "C:\\Users\\%USERNAME%\\app.exe", + unicode: "C:\\Users\\%USERNAME%\\app.exe", + } as const; + const variableOnly = await parse( + windowsShortcutBytes({ + flags: LINK_FLAG.IsUnicode | LINK_FLAG.HasIconLocation, + iconLocation: "C:\\Users\\Alice\\app.exe", + extraData: [environmentTarget], + }), + ); + expect(iconDifference(variableOnly)).toBeNull(); + + const withFixedTarget = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Tools\\app.exe" }, + iconLocation: "C:\\Users\\Alice\\app.exe", + extraData: [environmentTarget], + }), + ); + expect(iconDifference(withFixedTarget)).toBeNull(); + + const matchesFixed = await parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: "C:\\Tools\\app.exe" }, + iconLocation: "C:\\Tools\\app.exe", + extraData: [environmentTarget], + }), + ); + expect(iconDifference(matchesFixed)).toBe(false); +}); + +it("charges the string budget before decoding a LinkInfo string", async () => { + await expect( + parse( + windowsShortcutBytes({ + linkInfo: { localBasePath: `C:\\${"a".repeat(4096)}` }, + }), + { max_string_bytes: 1 }, + ), + ).rejects.toBeInstanceOf(WindowsShortcutResourceFailure); +}); diff --git a/src/native/windows/WindowsShortcutParser.ts b/src/native/windows/WindowsShortcutParser.ts new file mode 100644 index 0000000000..6b8c88d9c9 --- /dev/null +++ b/src/native/windows/WindowsShortcutParser.ts @@ -0,0 +1,1213 @@ +import { createHash } from "node:crypto"; +import { setImmediate } from "node:timers/promises"; + +import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.js"; +import type { + InspectWindowsShortcutInput, + WindowsShortcut, + WindowsShortcutExtraDataBlock, + WindowsShortcutIdListItem, + WindowsShortcutIssue, + WindowsShortcutStringField, + WindowsShortcutUuidV1, +} from "../../domain/native/windowsShortcut.js"; +import { + WindowsShortcutFormatFailure, + WindowsShortcutResourceFailure, +} from "./WindowsShortcut.errors.js"; + +type Artifact = WindowsShortcut["artifact"]; + +export const SHELL_LINK_CLSID = "00021401-0000-0000-C000-000000000046"; +const HEADER_BYTES = 76; +const FILETIME_UNIX_EPOCH = 116444736000000000n; +const UUID_UNIX_EPOCH = 0x01b21dd213814000n; +const ENTRIES_PER_YIELD = 32; +const VOLUME_UNICODE_LABEL_SELECTOR = 0x14; +/** ECMAScript Date range: 8.64e15 ms either side of the Unix epoch. */ +const MAX_DATE_MS = 8_640_000_000_000_000n; + +const isoFromUnixMs = (unixMs: bigint): string | null => + unixMs < -MAX_DATE_MS || unixMs > MAX_DATE_MS + ? null + : new Date(Number(unixMs)).toISOString(); + +const LINK_FLAG_NAMES: ReadonlyArray = [ + [0x00000001, "HasLinkTargetIDList"], + [0x00000002, "HasLinkInfo"], + [0x00000004, "HasName"], + [0x00000008, "HasRelativePath"], + [0x00000010, "HasWorkingDir"], + [0x00000020, "HasArguments"], + [0x00000040, "HasIconLocation"], + [0x00000080, "IsUnicode"], + [0x00000100, "ForceNoLinkInfo"], + [0x00000200, "HasExpString"], + [0x00000400, "RunInSeparateProcess"], + [0x00001000, "HasDarwinID"], + [0x00002000, "RunAsUser"], + [0x00004000, "HasExpIcon"], + [0x00008000, "NoPidlAlias"], + [0x00020000, "RunWithShimLayer"], + [0x00040000, "ForceNoLinkTrack"], + [0x00080000, "EnableTargetMetadata"], + [0x00100000, "DisableLinkPathTracking"], + [0x00200000, "DisableKnownFolderTracking"], + [0x00400000, "DisableKnownFolderAlias"], + [0x00800000, "AllowLinkToLink"], + [0x01000000, "UnaliasOnSave"], + [0x02000000, "PreferEnvironmentPath"], + [0x04000000, "KeepLocalIDListForUNCTarget"], +]; + +const FILE_ATTRIBUTE_NAMES: ReadonlyArray = [ + [0x00000001, "READONLY"], + [0x00000002, "HIDDEN"], + [0x00000004, "SYSTEM"], + [0x00000010, "DIRECTORY"], + [0x00000020, "ARCHIVE"], + [0x00000080, "NORMAL"], + [0x00000100, "TEMPORARY"], + [0x00000200, "SPARSE_FILE"], + [0x00000400, "REPARSE_POINT"], + [0x00000800, "COMPRESSED"], + [0x00001000, "OFFLINE"], + [0x00002000, "NOT_CONTENT_INDEXED"], + [0x00004000, "ENCRYPTED"], +]; + +const DRIVE_TYPES = [ + "unknown", + "no_root_dir", + "removable", + "fixed", + "remote", + "cdrom", + "ramdisk", +] as const; + +const EXTRA_SIGNATURE_NAMES: Readonly> = { + 0xa0000001: "EnvironmentVariableDataBlock", + 0xa0000002: "ConsoleDataBlock", + 0xa0000003: "TrackerDataBlock", + 0xa0000004: "ConsoleFEDataBlock", + 0xa0000005: "SpecialFolderDataBlock", + 0xa0000006: "DarwinDataBlock", + 0xa0000007: "IconEnvironmentDataBlock", + 0xa0000008: "ShimDataBlock", + 0xa0000009: "PropertyStoreDataBlock", + 0xa000000b: "KnownFolderDataBlock", + 0xa000000c: "VistaAndAboveIDListDataBlock", +}; + +const VIRTUAL_KEYS: Readonly> = { + 0x90: "NUMLOCK", + 0x91: "SCROLL", + ...Object.fromEntries( + Array.from({ length: 10 }, (_, index) => [0x30 + index, String(index)]), + ), + ...Object.fromEntries( + Array.from({ length: 26 }, (_, index) => [ + 0x41 + index, + String.fromCharCode(65 + index), + ]), + ), + ...Object.fromEntries( + Array.from({ length: 24 }, (_, index) => [ + 0x70 + index, + `F${String(index + 1)}`, + ]), + ), +}; + +const LIMITATIONS = [ + "Rows reproduce the selected Shell Link bytes; they do not resolve, open, or execute the link target.", + "ANSI strings use a byte-preserving Latin1 projection. The original system ANSI code page is unobserved.", + "Volume serial, NetBIOS machine id, and Tracker droid values are recorded as stored. Host identity is not verified.", +]; + +const sha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const namedBits = ( + raw: number, + names: ReadonlyArray, +): { raw: number; decoded: string[] } => ({ + raw, + decoded: names.flatMap(([bit, name]) => ((raw & bit) !== 0 ? [name] : [])), +}); + +const formatGuid = (bytes: Buffer, offset: number): string => { + const d1 = bytes.readUInt32LE(offset).toString(16).padStart(8, "0"); + const d2 = bytes + .readUInt16LE(offset + 4) + .toString(16) + .padStart(4, "0"); + const d3 = bytes + .readUInt16LE(offset + 6) + .toString(16) + .padStart(4, "0"); + const d4 = bytes.subarray(offset + 8, offset + 10).toString("hex"); + const d5 = bytes.subarray(offset + 10, offset + 16).toString("hex"); + return `${d1}-${d2}-${d3}-${d4}-${d5}`.toUpperCase(); +}; + +const fileTime = (raw: bigint): WindowsShortcut["header"]["creation_time"] => { + if (raw === 0n) return { raw: "0", iso8601: null }; + return { + raw: raw.toString(10), + iso8601: isoFromUnixMs((raw - FILETIME_UNIX_EPOCH) / 10000n), + }; +}; + +const showCommand = ( + raw: number, +): WindowsShortcut["header"]["show_command"] => { + if (raw === 3) return { raw, decoded: "maximized" }; + if (raw === 7) return { raw, decoded: "minimized" }; + return { raw, decoded: "normal" }; +}; + +const hotKey = (raw: number): WindowsShortcut["header"]["hotkey"] => { + const modifiers: Array<"shift" | "control" | "alt"> = []; + const high = (raw >> 8) & 0xff; + if ((high & 0x01) !== 0) modifiers.push("shift"); + if ((high & 0x02) !== 0) modifiers.push("control"); + if ((high & 0x04) !== 0) modifiers.push("alt"); + const virtualKey = raw & 0xff; + if (raw === 0) { + return { + raw, + modifiers: [], + virtual_key: null, + virtual_key_name: null, + }; + } + return { + raw, + modifiers, + virtual_key: virtualKey, + virtual_key_name: VIRTUAL_KEYS[virtualKey] ?? null, + }; +}; + +const latin1Z = (bytes: Buffer): string => { + const end = bytes.indexOf(0); + return bytes.subarray(0, end === -1 ? bytes.length : end).toString("latin1"); +}; + +/** Byte offset of the first UTF-16LE NUL code unit, or -1 when unterminated. */ +const utf16TerminatorOf = (bytes: Buffer): number => { + for (let index = 0; index + 1 < bytes.length; index += 2) { + if (bytes[index] === 0 && bytes[index + 1] === 0) return index; + } + return -1; +}; + +const utf16Z = (bytes: Buffer): string => { + const end = utf16TerminatorOf(bytes); + return bytes + .subarray(0, end === -1 ? bytes.length - (bytes.length % 2) : end) + .toString("utf16le"); +}; + +const uuidV1 = ( + bytes: Buffer, + offset: number, +): WindowsShortcutUuidV1 | null => { + const timeHiVersion = bytes.readUInt16LE(offset + 6); + if (timeHiVersion >> 12 !== 1) return null; + // RFC 4122 variant: clock_seq_hi_and_reserved must have top bits 10. + if (((bytes[offset + 8] ?? 0) & 0xc0) !== 0x80) return null; + const timeLow = BigInt(bytes.readUInt32LE(offset)); + const timeMid = BigInt(bytes.readUInt16LE(offset + 4)); + const timeHi = BigInt(timeHiVersion & 0x0fff); + const timestamp = (timeHi << 48n) | (timeMid << 32n) | timeLow; + return { + evidence_kind: "derived", + version: 1, + mac: [...bytes.subarray(offset + 10, offset + 16)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(":"), + timestamp_iso8601: isoFromUnixMs((timestamp - UUID_UNIX_EPOCH) / 10000n), + timestamp_100ns: timestamp.toString(10), + }; +}; + +const driveType = ( + raw: number, +): { + readonly raw: number; + readonly name: (typeof DRIVE_TYPES)[number] | null; +} => ({ + raw, + name: DRIVE_TYPES[raw] ?? null, +}); + +const extensionOf = (path: string | null | undefined): string | null => { + if (path === undefined || path === null || path.length === 0) return null; + const normalized = path.replaceAll("/", "\\"); + const slash = normalized.lastIndexOf("\\"); + const name = slash === -1 ? normalized : normalized.slice(slash + 1); + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) return null; + return name.slice(dot).toLowerCase(); +}; + +const foldPath = (path: string): string => + path.replaceAll("/", "\\").toLowerCase(); + +/** `\\?\` and `\\.\` paths reach Windows verbatim, so their segments are kept as written. */ +const DEVICE_PATH = /^\\\\[?.]\\/u; + +/** + * Compare form of a full path, from the text alone: no path is ever opened. + * Folds case and separators, drops repeated separators and `.` segments, and + * lets `..` remove one segment but never the drive or UNC `\\server\share` root. + */ +const normalizePath = (path: string): string => { + const folded = foldPath(path); + if (DEVICE_PATH.test(folded)) return folded; + const unc = folded.startsWith("\\\\"); + if (!unc && !/^[a-z]:\\/u.test(folded)) return folded; + // Empty parts drop out first, so `\\server\\share` keeps its share in the root. + const parts = folded + .slice(unc ? 2 : 0) + .split("\\") + .filter((part) => part !== ""); + const root = parts.slice(0, unc ? 2 : 1); + const segments: string[] = []; + for (const part of parts.slice(root.length)) { + if (part === ".") continue; + if (part === "..") segments.pop(); + else segments.push(part); + } + return unc + ? `\\\\${[...root, ...segments].join("\\")}` + : `${root[0] ?? ""}\\${segments.join("\\")}`; +}; + +/** A drive or UNC path. Filenames, relative paths, and `%VAR%` forms name no fixed location. */ +const isFullPath = (path: string): boolean => + /^(?:[a-z]:\\|\\\\)/u.test(foldPath(path)); + +/** An unexpanded `%VAR%` reference: the host is never consulted, so the path stays unresolved. */ +const hasUnexpandedVariable = (path: string): boolean => + /%[^%\\/]+%/u.test(path); + +const iconDiffersFromTarget = ( + icon: string | undefined, + targets: readonly string[], +): boolean | null => { + if (icon === undefined || !isFullPath(icon) || hasUnexpandedVariable(icon)) + return null; + const location = normalizePath(icon); + const fixedTargets = targets.filter( + (target) => isFullPath(target) && !hasUnexpandedVariable(target), + ); + if (fixedTargets.some((target) => normalizePath(target) === location)) + return false; + // A variable-bearing target could name the icon's file, so no difference is claimed. + if (targets.some((t) => isFullPath(t) && hasUnexpandedVariable(t))) + return null; + return fixedTargets.length === 0 ? null : true; +}; + +const targetPaths = (report: { + readonly link_info: WindowsShortcut["link_info"]; + readonly string_data: WindowsShortcut["string_data"]; + readonly id_list: WindowsShortcut["id_list"]; + readonly extra_data: WindowsShortcut["extra_data"]; +}): string[] => { + const values: string[] = []; + const info = report.link_info; + if (info !== null) { + const local = info.local_base_path_unicode ?? info.local_base_path; + const suffix = info.common_path_suffix_unicode ?? info.common_path_suffix; + if (local !== null && suffix !== null && suffix.length > 0) + values.push( + `${local.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, + ); + else if (local !== null && local.length > 0) values.push(local); + const net = info.common_network_relative_link; + if (net !== null) { + const share = net.net_name_unicode ?? net.net_name; + if (share !== null && suffix !== null && suffix.length > 0) + values.push( + `${share.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, + ); + else if (share !== null) values.push(share); + } + } + if (report.string_data.relative_path !== null) + values.push(report.string_data.relative_path.value); + if (report.id_list !== null) { + const lastFile = [...report.id_list.items] + .reverse() + .find((item) => item.kind === "file_entry"); + if (lastFile?.kind === "file_entry") values.push(lastFile.short_name); + } + // A stored literal target, never expanded: `%VAR%` text stays as written. + for (const block of report.extra_data.blocks) { + if (block.decoded.kind !== "environment_variable") continue; + const stored = + block.decoded.target_unicode.length > 0 + ? block.decoded.target_unicode + : block.decoded.target_ansi; + if (stored.length > 0) values.push(stored); + } + return values; +}; + +const observationsOf = ( + report: Pick< + WindowsShortcut, + "header" | "link_info" | "string_data" | "id_list" | "extra_data" + >, +): WindowsShortcut["observations"] => { + const argumentsDeclared = (report.header.link_flags.raw & 0x00000020) !== 0; + const argumentsField = report.string_data.arguments; + // HasArguments set but the field was not read: presence and length stay unknown. + const argumentsValue = + argumentsField !== null + ? argumentsField.value + : argumentsDeclared + ? null + : ""; + const targets = targetPaths(report); + return [ + { + kind: "arguments_present", + evidence_kind: "derived", + value: argumentsValue === null ? null : argumentsValue.length > 0, + }, + { + kind: "argument_length", + evidence_kind: "derived", + characters: argumentsValue === null ? null : argumentsValue.length, + }, + { + kind: "target_extension", + evidence_kind: "derived", + extension: extensionOf(targets[0] ?? null), + }, + { + kind: "show_command_minimized", + evidence_kind: "derived", + value: report.header.show_command.raw === 7, + }, + { + kind: "icon_location_differs_from_target", + evidence_kind: "derived", + value: iconDiffersFromTarget( + report.string_data.icon_location?.value, + targets, + ), + }, + ]; +}; + +const decodeIdListItem = ( + bytes: Buffer, + offset: number, + size: number, +): WindowsShortcutIdListItem => { + const data = bytes.subarray(offset + 2, offset + size); + const typeByte = data.length === 0 ? 0 : (data[0] ?? 0); + const raw = (): WindowsShortcutIdListItem => ({ + kind: "raw", + offset, + size, + type_byte: typeByte, + sha256: sha256(data), + }); + if (typeByte === 0x1f && data.length >= 18) { + return { + kind: "root_folder", + offset, + size, + type_byte: typeByte, + sort_index: data[1] ?? 0, + folder_id: formatGuid(data, 2), + }; + } + if (typeByte === 0x2f && data.length >= 2) { + const name = latin1Z(data.subarray(1)); + if (name.length === 0) return raw(); + return { + kind: "volume", + offset, + size, + type_byte: typeByte, + name, + }; + } + if ((typeByte & 0x70) === 0x30 && data.length >= 14) { + // Bit 0x04 marks a UTF-16LE name, which a Latin-1 read stops at the first zero byte. + const nameBytes = data.subarray(12); + const name = + (typeByte & 0x04) !== 0 ? utf16Z(nameBytes) : latin1Z(nameBytes); + if (name.length === 0 || /[\u0001-\u001f]/u.test(name)) return raw(); + return { + kind: "file_entry", + offset, + size, + type_byte: typeByte, + entry_class: (typeByte & 0x01) !== 0 ? "directory" : "file", + file_size: data.readUInt32LE(2), + short_name: name, + }; + } + return raw(); +}; + +type Walk = { + offset: number; + issues: WindowsShortcutIssue[]; + failingOffset: number | null; + stopped: boolean; + stringBytes: number; + maxStringBytes: number; +}; + +const note = ( + walk: Walk, + code: WindowsShortcutIssue["code"], + offset: number, + message: string, + stop = false, +): void => { + walk.issues.push({ code, offset, message }); + walk.failingOffset ??= offset; + if (stop) walk.stopped = true; +}; + +const charge = (walk: Walk, bytes: number): void => { + walk.stringBytes += bytes; + if (walk.stringBytes > walk.maxStringBytes) + throw new WindowsShortcutResourceFailure( + "string_bytes", + walk.maxStringBytes, + `Shell Link strings exceed the ${String(walk.maxStringBytes)}-byte retained-text allowance`, + ); +}; + +const fits = (bytes: Buffer, offset: number, size: number): boolean => + offset >= 0 && + size >= 0 && + offset <= bytes.length && + size <= bytes.length - offset; + +const readAnsiAt = ( + view: Buffer, + base: number, + offset: number, + field: number, + walk: Walk, +): string | null => { + if (offset === 0) return null; + if (offset >= view.length) { + note( + walk, + "malformed_structure", + base + field, + "ANSI string offset is outside LinkInfo", + ); + return null; + } + const slice = view.subarray(offset); + const end = slice.indexOf(0); + if (end === -1) { + note( + walk, + "malformed_structure", + base + offset, + "ANSI string has no NUL terminator inside its structure", + ); + return null; + } + charge(walk, end + 1); + return slice.subarray(0, end).toString("latin1"); +}; + +const readUtf16At = ( + view: Buffer, + base: number, + offset: number, + field: number, + walk: Walk, +): string | null => { + if (offset === 0) return null; + if (offset >= view.length) { + note( + walk, + "malformed_structure", + base + field, + "Unicode string offset is outside LinkInfo", + ); + return null; + } + const slice = view.subarray(offset); + const end = utf16TerminatorOf(slice); + if (end === -1) { + note( + walk, + slice.length % 2 === 1 ? "odd_unicode_length" : "malformed_structure", + base + offset, + "Unicode string has no NUL terminator inside its structure", + ); + return null; + } + charge(walk, end + 2); + return slice.subarray(0, end).toString("utf16le"); +}; + +const parseVolume = ( + view: Buffer, + base: number, + offset: number, + field: number, + walk: Walk, +): NonNullable["volume"] => { + if (offset === 0) return null; + if (!fits(view, offset, 16)) { + note( + walk, + "truncated", + offset < view.length ? base + offset : base + field, + "VolumeID is truncated", + ); + return null; + } + const size = view.readUInt32LE(offset); + if (size < 16 || !fits(view, offset, size)) { + note( + walk, + "block_size_overrun", + base + offset, + "VolumeID size overruns LinkInfo", + ); + return null; + } + const volume = view.subarray(offset, offset + size); + const labelOffset = volume.readUInt32LE(12); + // MS-SHLLINK 2.3.1: exactly 0x14 selects VolumeLabelOffsetUnicode, and that + // field exists for no other value. The two label encodings never coexist. + const hasUnicodeLabel = labelOffset === VOLUME_UNICODE_LABEL_SELECTOR; + if (hasUnicodeLabel && size < 20) + note( + walk, + "truncated", + base + offset + 16, + "VolumeID ends before VolumeLabelOffsetUnicode", + ); + return { + drive_type: driveType(volume.readUInt32LE(4)), + serial: volume.readUInt32LE(8).toString(16).toUpperCase().padStart(8, "0"), + label: hasUnicodeLabel + ? null + : readAnsiAt(volume, base + offset, labelOffset, 12, walk), + label_unicode: + hasUnicodeLabel && size >= 20 + ? readUtf16At(volume, base + offset, volume.readUInt32LE(16), 16, walk) + : null, + }; +}; + +const parseNetwork = ( + view: Buffer, + base: number, + offset: number, + field: number, + walk: Walk, +): NonNullable< + WindowsShortcut["link_info"] +>["common_network_relative_link"] => { + if (offset === 0) return null; + if (!fits(view, offset, 20)) { + note( + walk, + "truncated", + offset < view.length ? base + offset : base + field, + "CommonNetworkRelativeLink is truncated", + ); + return null; + } + const size = view.readUInt32LE(offset); + if (size < 20 || !fits(view, offset, size)) { + note( + walk, + "block_size_overrun", + base + offset, + "CommonNetworkRelativeLink size overruns LinkInfo", + ); + return null; + } + const block = view.subarray(offset, offset + size); + // MS-SHLLINK 2.3.2: DeviceNameOffset (and its Unicode twin) are only + // meaningful when ValidDevice is set; otherwise the fields are ignored. + const flags = block.readUInt32LE(4); + const validDevice = (flags & 0x1) !== 0; + const validNetType = (flags & 0x2) !== 0; + const netNameOffset = block.readUInt32LE(8); + const deviceNameOffset = validDevice ? block.readUInt32LE(12) : 0; + const unicode = netNameOffset > 0x14 && size >= 0x1c; + const netNameOffsetUnicode = unicode ? block.readUInt32LE(20) : 0; + const deviceNameOffsetUnicode = + unicode && validDevice ? block.readUInt32LE(24) : 0; + return { + net_name: readAnsiAt(block, base + offset, netNameOffset, 8, walk), + net_name_unicode: readUtf16At( + block, + base + offset, + netNameOffsetUnicode, + 20, + walk, + ), + device_name: readAnsiAt(block, base + offset, deviceNameOffset, 12, walk), + device_name_unicode: readUtf16At( + block, + base + offset, + deviceNameOffsetUnicode, + 24, + walk, + ), + network_provider_type: validNetType ? block.readUInt32LE(16) : null, + }; +}; + +const parseLinkInfo = ( + bytes: Buffer, + walk: Walk, +): WindowsShortcut["link_info"] => { + if (!fits(bytes, walk.offset, 8)) { + note(walk, "truncated", walk.offset, "LinkInfo is truncated", true); + return null; + } + const size = bytes.readUInt32LE(walk.offset); + if (size < 0x1c || !fits(bytes, walk.offset, size)) { + note( + walk, + "block_size_overrun", + walk.offset, + "LinkInfo size overruns the file", + true, + ); + return null; + } + const view = bytes.subarray(walk.offset, walk.offset + size); + const headerSize = view.readUInt32LE(4); + if (headerSize < 0x1c || headerSize > size) + note( + walk, + "malformed_structure", + walk.offset + 4, + `LinkInfoHeaderSize ${String(headerSize)} is outside 0x1C..LinkInfoSize`, + ); + const flags = view.readUInt32LE(8); + const volumeOffset = view.readUInt32LE(12); + const localOffset = view.readUInt32LE(16); + const networkOffset = view.readUInt32LE(20); + const suffixOffset = view.readUInt32LE(24); + const unicode = headerSize >= 0x24 && view.length >= 0x24; + const localUnicodeOffset = unicode ? view.readUInt32LE(28) : 0; + const suffixUnicodeOffset = unicode ? view.readUInt32LE(32) : 0; + const info: NonNullable = { + location: { offset: walk.offset, bytes: size }, + header_size: headerSize, + flags: { + raw: flags, + volume_id_and_local_base_path: (flags & 0x1) !== 0, + common_network_relative_link_and_path_suffix: (flags & 0x2) !== 0, + }, + volume: + (flags & 0x1) !== 0 + ? parseVolume(view, walk.offset, volumeOffset, 12, walk) + : null, + local_base_path: + (flags & 0x1) !== 0 + ? readAnsiAt(view, walk.offset, localOffset, 16, walk) + : null, + local_base_path_unicode: + (flags & 0x1) !== 0 + ? readUtf16At(view, walk.offset, localUnicodeOffset, 28, walk) + : null, + common_network_relative_link: + (flags & 0x2) !== 0 + ? parseNetwork(view, walk.offset, networkOffset, 20, walk) + : null, + common_path_suffix: readAnsiAt(view, walk.offset, suffixOffset, 24, walk), + common_path_suffix_unicode: readUtf16At( + view, + walk.offset, + suffixUnicodeOffset, + 32, + walk, + ), + }; + walk.offset += size; + return info; +}; + +const parseStringField = ( + bytes: Buffer, + walk: Walk, + unicode: boolean, + label: string, +): WindowsShortcutStringField | null => { + if (!fits(bytes, walk.offset, 2)) { + note( + walk, + "truncated", + walk.offset, + `${label} CountCharacters is truncated`, + true, + ); + return null; + } + const count = bytes.readUInt16LE(walk.offset); + const unit = unicode ? 2 : 1; + const payloadBytes = count * unit; + if (unicode && payloadBytes % 2 === 1) { + note( + walk, + "odd_unicode_length", + walk.offset, + `${label} Unicode payload is an odd length`, + true, + ); + return null; + } + if (!fits(bytes, walk.offset + 2, payloadBytes)) { + const remaining = bytes.length - (walk.offset + 2); + note( + walk, + remaining % 2 === 1 && unicode ? "odd_unicode_length" : "truncated", + walk.offset, + `${label} string bytes are truncated`, + true, + ); + return null; + } + const payload = bytes.subarray( + walk.offset + 2, + walk.offset + 2 + payloadBytes, + ); + charge(walk, payloadBytes); + const value = payload.toString(unicode ? "utf16le" : "latin1"); + const field: WindowsShortcutStringField = { + encoding: unicode ? "unicode" : "ansi", + count_characters: count, + byte_length: payloadBytes, + value, + location: { offset: walk.offset, bytes: 2 + payloadBytes }, + }; + walk.offset += 2 + payloadBytes; + return field; +}; + +const environmentTargets = ( + payload: Buffer, +): { target_ansi: string; target_unicode: string } | null => { + if (payload.length < 780) return null; + return { + target_ansi: latin1Z(payload.subarray(0, 260)), + target_unicode: utf16Z(payload.subarray(260, 780)), + }; +}; + +const decodeExtra = ( + signature: number, + payload: Buffer, + walk: Walk, + offset: number, +): WindowsShortcutExtraDataBlock["decoded"] => { + if (signature === 0xa0000001 || signature === 0xa0000007) { + const targets = environmentTargets(payload); + if (targets === null) { + note( + walk, + "truncated", + offset, + "Environment ExtraData payload is shorter than 780 bytes", + ); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + charge( + walk, + targets.target_ansi.length + targets.target_unicode.length * 2, + ); + return { + kind: + signature === 0xa0000001 ? "environment_variable" : "icon_environment", + ...targets, + }; + } + if (signature === 0xa0000003) { + if (payload.length < 88) { + note( + walk, + "truncated", + offset, + "TrackerDataBlock payload is shorter than 0x58 bytes", + ); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + const length = payload.readUInt32LE(0); + const version = payload.readUInt32LE(4); + if (length !== 0x58 || version !== 0) { + note( + walk, + "malformed_structure", + offset, + `TrackerDataBlock header is unsupported (Length 0x${length.toString(16)}, Version ${String(version)})`, + ); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + const machine = latin1Z(payload.subarray(8, 24)); + charge(walk, machine.length); + const droid0 = formatGuid(payload, 24); + const droid1 = formatGuid(payload, 40); + const birth0 = formatGuid(payload, 56); + const birth1 = formatGuid(payload, 72); + return { + kind: "tracker", + machine_id: machine, + droid: [droid0, droid1], + birth_droid: [birth0, birth1], + droid_uuid_v1: [uuidV1(payload, 24), uuidV1(payload, 40)], + birth_droid_uuid_v1: [uuidV1(payload, 56), uuidV1(payload, 72)], + }; + } + if (signature === 0xa0000004) { + if (payload.length < 4) { + note(walk, "truncated", offset, "ConsoleFEDataBlock is truncated"); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + return { kind: "console_fe", code_page: payload.readUInt32LE(0) }; + } + if (signature === 0xa0000005) { + if (payload.length < 8) { + note(walk, "truncated", offset, "SpecialFolderDataBlock is truncated"); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + return { + kind: "special_folder", + folder_id: payload.readUInt32LE(0), + item_offset: payload.readUInt32LE(4), + }; + } + if (signature === 0xa000000b) { + if (payload.length < 20) { + note(walk, "truncated", offset, "KnownFolderDataBlock is truncated"); + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; + } + return { + kind: "known_folder", + folder_id: formatGuid(payload, 0), + item_offset: payload.readUInt32LE(16), + }; + } + if (signature === 0xa0000009) + return { + kind: "property_store", + bytes: payload.length, + sha256: sha256(payload), + }; + return { kind: "raw", bytes: payload.length, sha256: sha256(payload) }; +}; + +const parseIdList = async ( + bytes: Buffer, + walk: Walk, + maxItems: number, + signal?: AbortSignal, +): Promise => { + if (!fits(bytes, walk.offset, 2)) { + note( + walk, + "truncated", + walk.offset, + "LinkTargetIDList size is truncated", + true, + ); + return null; + } + const byteSize = bytes.readUInt16LE(walk.offset); + const start = walk.offset; + if (!fits(bytes, walk.offset + 2, byteSize)) { + note( + walk, + "truncated", + walk.offset, + "LinkTargetIDList overruns the file", + true, + ); + return null; + } + const end = walk.offset + 2 + byteSize; + let cursor = walk.offset + 2; + const items: WindowsShortcutIdListItem[] = []; + let terminated = false; + let malformed = false; + while (cursor + 2 <= end) { + if (items.length > 0 && items.length % ENTRIES_PER_YIELD === 0) { + await setImmediate(); + assertArtifactReadActive(signal); + } + const size = bytes.readUInt16LE(cursor); + if (size === 0) { + cursor += 2; + terminated = true; + break; + } + if (size < 2 || cursor + size > end) { + note( + walk, + "malformed_structure", + cursor, + "ItemID size overruns the IDList", + ); + malformed = true; + break; + } + if (items.length + 1 > maxItems) + throw new WindowsShortcutResourceFailure( + "id_list_items", + maxItems, + `LinkTargetIDList declares more than the ${String(maxItems)}-item allowance`, + ); + items.push(decodeIdListItem(bytes, cursor, size)); + cursor += size; + } + if (!terminated && !malformed) + note( + walk, + "malformed_structure", + cursor, + "LinkTargetIDList has no TerminalID inside its declared size", + ); + walk.offset = end; + return { + location: { offset: start, bytes: 2 + byteSize }, + byte_size: byteSize, + item_count: items.length, + items, + }; +}; + +const EXPECTED_EXTRA_DATA_BLOCKS = [ + { + flag: 0x00000200, + flagName: "HasExpString", + signature: 0xa0000001, + blockName: "EnvironmentVariableDataBlock", + }, + { + flag: 0x00004000, + flagName: "HasExpIcon", + signature: 0xa0000007, + blockName: "IconEnvironmentDataBlock", + }, +] as const; + +const parseExtraData = async ( + bytes: Buffer, + walk: Walk, + maxBlocks: number, + signal?: AbortSignal, +): Promise => { + const blocks: WindowsShortcutExtraDataBlock[] = []; + let terminalOffset: number | null = null; + while (walk.offset < bytes.length) { + if (blocks.length > 0 && blocks.length % ENTRIES_PER_YIELD === 0) { + await setImmediate(); + assertArtifactReadActive(signal); + } + if (!fits(bytes, walk.offset, 4)) { + note( + walk, + "truncated", + walk.offset, + "ExtraData is truncated before a block size", + true, + ); + break; + } + const size = bytes.readUInt32LE(walk.offset); + // TerminalSize MUST be below 4; any such value ends ExtraData. + if (size < 4) { + terminalOffset = walk.offset; + walk.offset += 4; + break; + } + if (size < 8) { + note( + walk, + "malformed_structure", + walk.offset, + `ExtraData block size ${String(size)} is below 8 bytes`, + ); + break; + } + if (!fits(bytes, walk.offset, size)) { + note( + walk, + "block_size_overrun", + walk.offset, + "ExtraData block overruns the file", + true, + ); + break; + } + if (blocks.length + 1 > maxBlocks) + throw new WindowsShortcutResourceFailure( + "extra_data_blocks", + maxBlocks, + `ExtraData declares more than the ${String(maxBlocks)}-block allowance`, + ); + const signature = bytes.readUInt32LE(walk.offset + 4); + const payload = bytes.subarray(walk.offset + 8, walk.offset + size); + blocks.push({ + offset: walk.offset, + size, + signature, + signature_name: EXTRA_SIGNATURE_NAMES[signature] ?? null, + decoded: decodeExtra(signature, payload, walk, walk.offset), + }); + walk.offset += size; + } + if (blocks.length > 0 && terminalOffset === null) + note(walk, "truncated", walk.offset, "ExtraData TerminalBlock is absent"); + return { blocks, terminal_offset: terminalOffset }; +}; + +/** + * Read one Shell Link without resolving or executing the target. + * Yields to the event loop and honors cancellation every + * `ENTRIES_PER_YIELD` IDList items or ExtraData blocks. + */ +export const parseWindowsShortcut = async ( + bytes: Buffer, + artifact: Artifact, + input: InspectWindowsShortcutInput, + signal?: AbortSignal, +): Promise => { + assertArtifactReadActive(signal); + if (bytes.length < HEADER_BYTES) + throw new WindowsShortcutFormatFailure( + "malformed", + `File is ${String(bytes.length)} bytes, below the 76-byte ShellLinkHeader`, + ); + const headerSize = bytes.readUInt32LE(0); + if (headerSize !== HEADER_BYTES) + throw new WindowsShortcutFormatFailure( + "malformed", + `HeaderSize ${String(headerSize)} is not 0x4C`, + ); + const clsid = formatGuid(bytes, 4); + if (clsid !== SHELL_LINK_CLSID) + throw new WindowsShortcutFormatFailure( + "malformed", + `LinkCLSID ${clsid} is not ${SHELL_LINK_CLSID}`, + ); + const flags = bytes.readUInt32LE(20); + const unicode = (flags & 0x00000080) !== 0; + const header: WindowsShortcut["header"] = { + location: { offset: 0, bytes: HEADER_BYTES }, + header_size: headerSize, + clsid, + link_flags: namedBits(flags, LINK_FLAG_NAMES), + file_attributes: namedBits(bytes.readUInt32LE(24), FILE_ATTRIBUTE_NAMES), + creation_time: fileTime(bytes.readBigUInt64LE(28)), + access_time: fileTime(bytes.readBigUInt64LE(36)), + write_time: fileTime(bytes.readBigUInt64LE(44)), + file_size: bytes.readUInt32LE(52), + icon_index: bytes.readInt32LE(56), + show_command: showCommand(bytes.readUInt32LE(60)), + hotkey: hotKey(bytes.readUInt16LE(64)), + }; + const walk: Walk = { + offset: HEADER_BYTES, + issues: [], + failingOffset: null, + stopped: false, + stringBytes: 0, + maxStringBytes: input.max_string_bytes, + }; + const idList = + (flags & 0x00000001) !== 0 + ? await parseIdList(bytes, walk, input.max_id_list_items, signal) + : null; + const linkInfo = + (flags & 0x00000002) !== 0 && !walk.stopped + ? parseLinkInfo(bytes, walk) + : null; + const stringData: WindowsShortcut["string_data"] = { + name: null, + relative_path: null, + working_dir: null, + arguments: null, + icon_location: null, + }; + const stringFlags: ReadonlyArray< + readonly [number, keyof WindowsShortcut["string_data"], string] + > = [ + [0x00000004, "name", "NAME_STRING"], + [0x00000008, "relative_path", "RELATIVE_PATH"], + [0x00000010, "working_dir", "WORKING_DIR"], + [0x00000020, "arguments", "COMMAND_LINE_ARGUMENTS"], + [0x00000040, "icon_location", "ICON_LOCATION"], + ]; + if (!walk.stopped) { + for (const [bit, key, label] of stringFlags) { + if ((flags & bit) === 0) continue; + const field = parseStringField(bytes, walk, unicode, label); + stringData[key] = field; + if (field === null) break; + } + } + const extraData = !walk.stopped + ? await parseExtraData(bytes, walk, input.max_extra_data_blocks, signal) + : { blocks: [], terminal_offset: null }; + const signatures = new Set(extraData.blocks.map((block) => block.signature)); + const unseenBlocks = EXPECTED_EXTRA_DATA_BLOCKS.filter( + ({ flag, signature }) => (flags & flag) !== 0 && !signatures.has(signature), + ); + // Only a TerminalBlock proves the whole section was read. Without one an + // unseen block is unknown, not absent. + if (extraData.terminal_offset !== null) + for (const { flagName, blockName } of unseenBlocks) + note( + walk, + "missing_extra_data_block", + extraData.terminal_offset, + `${flagName} is set and ${blockName} is absent`, + ); + else if (unseenBlocks.length > 0 && walk.issues.length === 0) + note(walk, "truncated", walk.offset, "ExtraData TerminalBlock is absent"); + const reportBase = { + artifact, + header, + id_list: idList, + link_info: linkInfo, + string_data: stringData, + extra_data: extraData, + }; + const partial = walk.issues.length > 0; + return { + ...reportBase, + observations: observationsOf(reportBase), + coverage: { + status: partial ? "partial" : "complete", + reason: partial + ? `${String(walk.issues.length)} Shell Link issue(s) affect reported rows; see issues` + : null, + examined_id_list_items: idList?.items.length ?? 0, + examined_extra_data_blocks: extraData.blocks.length, + failing_offset: walk.failingOffset, + }, + issues: walk.issues, + limitations: LIMITATIONS, + }; +}; diff --git a/src/native/windows/WindowsShortcutProvider.ts b/src/native/windows/WindowsShortcutProvider.ts new file mode 100644 index 0000000000..02470710c0 --- /dev/null +++ b/src/native/windows/WindowsShortcutProvider.ts @@ -0,0 +1,390 @@ +import { lstat, open } from "node:fs/promises"; +import { + projectStableArtifactFailure, + projectArtifactCloseFailure, +} from "../../artifacts/StableArtifactFailure.js"; +import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + readStableArtifact, + type StableArtifactFileSystem, + type StableArtifactSnapshot, +} from "../../artifacts/readStableArtifact.js"; +import { fileSnapshotPartialObservationSchema } from "../../domain/fileSnapshotObservation.js"; +import { + artifactFilePrimaryFacts, + type ArtifactFilePrimaryFacts, + assertArtifactReadActive, + artifactFileCancellationCause, +} from "../../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import type { WindowsShortcutPort } from "../../application/binaryDiagnostics/WindowsShortcutPort.js"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; +import { + MAX_INSPECT_WINDOWS_SHORTCUT_EXTRA_DATA_BLOCKS, + MAX_INSPECT_WINDOWS_SHORTCUT_FILE_BYTES, + MAX_INSPECT_WINDOWS_SHORTCUT_ID_LIST_ITEMS, + MAX_INSPECT_WINDOWS_SHORTCUT_STRING_BYTES, + type InspectWindowsShortcutInput, +} from "../../domain/native/windowsShortcut.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { err, ok } from "../../domain/result.js"; +import { WINDOWS_SHORTCUT_PROVIDER } from "../../domain/investigationProviders.js"; +import { + WindowsShortcutFormatFailure, + WindowsShortcutResourceFailure, + type WindowsShortcutSelectedBoundary, +} from "./WindowsShortcut.errors.js"; +import { parseWindowsShortcut } from "./WindowsShortcutParser.js"; + +const OPERATION = "inspect_windows_shortcut"; + +/** A path that starts with two separators: UNC, extended UNC, or another device path. */ +const HOST_PREFIXED_PATH = /^[\\/]{2}/u; +/** An extended or device form of a drive path, such as `\\?\C:\x`, which stays on this host. */ +const LOCAL_DRIVE_DEVICE_PATH = /^[\\/]{2}[?.][\\/][A-Za-z]:(?:[\\/]|$)/u; + +/** + * A path Windows could open through a remote host. Even `lstat` on such a path + * can reach an SMB server and offer the caller's credentials, so this is decided + * from the text alone. A mapped network drive looks like a local drive here. + * POSIX hosts read repeated leading slashes as one local root. + */ +const namesNetworkShare = (path: string, platform: NodeJS.Platform): boolean => + platform === "win32" && + HOST_PREFIXED_PATH.test(path) && + !LOCAL_DRIVE_DEVICE_PATH.test(path); + +/** + * `lstat` and `open` only. Without `realpath`, a rejected symlink's destination + * is never resolved, so a link pointing at a UNC path cannot reach an SMB host. + */ +const LINK_BLIND_FILE_SYSTEM: StableArtifactFileSystem = { + lstat: (path) => lstat(path, { bigint: true }), + open, +}; + +/** Where a Windows path's ancestors begin: past a drive root or device prefix. */ +const WINDOWS_PATH_ROOT = /^(?:[\\/]{2}[?.][\\/])?(?:[A-Za-z]:)?[\\/]*/u; +/** A `\\?\` path, which Windows opens as spelled, without removing `.` or `..`. */ +const LITERAL_DEVICE_PATH = /^[\\/]{2}\?[\\/]/u; +/** One path component and the separators the caller wrote after it. */ +const WINDOWS_PATH_COMPONENT = /([^\\/]+)([\\/]*)/gu; + +/** + * The ancestor directories Windows reaches when it opens `path`, spelled as + * the caller wrote them. Win32 removes `.` and `..` components before opening + * a path, so `C:\alias\..\real\a.lnk` reaches only `C:\real`, and `..` + * cannot climb above an absolute root. A `\\?\` path skips that + * normalization, so its components are walked as written. A relative path + * keeps a leading `..` that has nothing to remove. + * + * Only the normalized components are retained, which is linear in the path. + * Each ancestor is built from the running prefix as the walk reaches it, so a + * deep path never holds every parent spelling at once. + */ +function* windowsAncestors(path: string): Generator { + const root = WINDOWS_PATH_ROOT.exec(path)?.[0] ?? ""; + const literal = LITERAL_DEVICE_PATH.test(root); + const absolute = /[\\/]$/u.test(root); + const kept: { readonly name: string; readonly spelled: string }[] = []; + for (const [spelled, name = ""] of path + .slice(root.length) + .matchAll(WINDOWS_PATH_COMPONENT)) { + if (!literal && name === ".") continue; + if (!literal && name === "..") { + const last = kept.at(-1); + if (last !== undefined && last.name !== "..") { + kept.pop(); + continue; + } + if (absolute) continue; + } + kept.push({ name, spelled }); + } + // The final component is the shortcut itself, not an ancestor. + let prefix = root; + for (const { name, spelled } of kept.slice(0, -1)) { + yield prefix + name; + prefix += spelled; + } +} + +/** + * The first ancestor directory of a Windows path that is a symbolic link or + * junction, walked from the root down. Each `lstat` reaches only directories + * already checked, so a link to a UNC path is found before anything follows it. + * An ancestor that cannot be read ends the walk and leaves the error to the reader. + * The caller's signal is checked before each read. + */ +const linkedWindowsAncestor = async ( + path: string, + signal: AbortSignal | undefined, +): Promise => { + for (const ancestor of windowsAncestors(path)) { + assertArtifactReadActive(signal); + try { + if ((await LINK_BLIND_FILE_SYSTEM.lstat(ancestor)).isSymbolicLink()) + return ancestor; + } catch { + return undefined; + } + } + return undefined; +}; + +/** Portable Shell Link reader over one bounded, identity-checked regular file. */ +export class WindowsShortcutProvider implements WindowsShortcutPort { + readonly identity = WINDOWS_SHORTCUT_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + constructor(private readonly platform: NodeJS.Platform = process.platform) {} + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + WINDOWS_SHORTCUT_PROVIDER.id, + OPERATION, + ); + } + } + + async inspect( + input: InspectWindowsShortcutInput, + options?: ExecutionOptions, + ) { + if (namesNetworkShare(input.path, this.platform)) + return err( + new AnalysisInputError(OPERATION, {}, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected shortcut is on a network share, which this offline operation does not open: ${input.path}. Copy it to a local path first.`, + }, + ]), + ); + let completed: StableArtifactSnapshot | undefined; + try { + return await this.#resources.run(async () => { + // Inside the admission, so close() waits for the walk's reads too. + const linkedAncestor = + this.platform === "win32" + ? await linkedWindowsAncestor(input.path, options?.signal) + : undefined; + if (linkedAncestor !== undefined) + return err( + new AnalysisInputError(OPERATION, {}, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected shortcut path passes through a directory link or junction, which this offline operation does not follow: ${linkedAncestor}. Select the shortcut by a path without links.`, + observed: { linked_ancestor: linkedAncestor }, + }, + ]), + ); + const snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + LINK_BLIND_FILE_SYSTEM, + this.#resources, + ); + completed = snapshot; + assertArtifactReadActive(options?.signal); + const report = await parseWindowsShortcut( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + options?.signal, + ); + assertArtifactReadActive(options?.signal); + return ok(report); + }); + } catch (cause) { + const failure = windowsShortcutFailure(cause, input); + if (completed !== undefined) + failure.retainPartialObservation( + fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-hash", + path: input.path, + sha256: completed.sha256, + bytes: completed.bytes.length, + }), + ); + return err(failure); + } + } +} + +const fileSizeRemediation = (input: InspectWindowsShortcutInput): string => + input.max_file_bytes >= MAX_INSPECT_WINDOWS_SHORTCUT_FILE_BYTES + ? `The shortcut exceeds the ${String(MAX_INSPECT_WINDOWS_SHORTCUT_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + +const SELECTED_BUDGETS = { + id_list_items: { + argument: "max_id_list_items", + maximum: MAX_INSPECT_WINDOWS_SHORTCUT_ID_LIST_ITEMS, + unit: "entries", + }, + extra_data_blocks: { + argument: "max_extra_data_blocks", + maximum: MAX_INSPECT_WINDOWS_SHORTCUT_EXTRA_DATA_BLOCKS, + unit: "entries", + }, + string_bytes: { + argument: "max_string_bytes", + maximum: MAX_INSPECT_WINDOWS_SHORTCUT_STRING_BYTES, + unit: "bytes", + }, +} as const satisfies Record< + WindowsShortcutSelectedBoundary, + { + readonly argument: keyof InspectWindowsShortcutInput; + readonly maximum: number; + readonly unit: string; + } +>; + +const budgetRemediation = ( + input: InspectWindowsShortcutInput, + boundary: WindowsShortcutSelectedBoundary, +): string => { + const { argument, maximum, unit } = SELECTED_BUDGETS[boundary]; + const selected = input[argument]; + const recovery = + selected >= maximum + ? `This release has no override above the ${String(maximum)} ${unit} ceiling; select a smaller shortcut.` + : `Raise ${argument} on the next call, up to ${String(maximum)} ${unit}, or select a smaller shortcut; no restart is required.`; + return `Effective ${argument} is ${String(selected)} ${unit}. ${recovery}`; +}; + +const windowsShortcutFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectWindowsShortcutInput, +): AnalysisError => { + const { cause, origin } = facts; + if (origin === "owned-or-classified" && cause instanceof AnalysisError) + return cause; + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { cause }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if (cause instanceof WindowsShortcutResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + `Selected artifact ${input.path}: ${cause.message}`, + { boundary: cause.boundary, maximum: cause.maximum }, + { + cause, + retryable: false, + remediationAction: budgetRemediation(input, cause.boundary), + }, + ); + if (cause instanceof WindowsShortcutFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${input.path}: ${cause.message}`, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + cause.message, + { max_file_bytes: input.max_file_bytes }, + { + cause, + retryable: false, + remediationAction: fileSizeRemediation(input), + }, + ); + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: "invalid_format", message: cause.message }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + if (isSelectedRegularFileError(cause)) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected shortcut could not be read (${String(cause.code)}): ${input.path}.`, + }, + ]); + } + return new ProviderAdapterError(WINDOWS_SHORTCUT_PROVIDER.id, OPERATION, { + cause, + diagnostics: { + path: input.path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }); +}; + +const windowsShortcutFailure = ( + cause: unknown, + input: InspectWindowsShortcutInput, +): AnalysisError => { + const primary = windowsShortcutFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + return projectStableArtifactFailure( + cause, + primary, + input.path, + WINDOWS_SHORTCUT_PROVIDER.id, + OPERATION, + ); +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index b2da447671..626a65ef2a 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -25,6 +25,7 @@ import { registerPeLoadConfigTool } from "./registerPeLoadConfigTool.js"; import { registerBinaryHardeningTool } from "./registerBinaryHardeningTool.js"; import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js"; import { registerPeImportsTool } from "./registerPeImportsTool.js"; +import { registerWindowsShortcutTool } from "./registerWindowsShortcutTool.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import { registerEvmTools } from "./registerEvmTools.js"; @@ -224,6 +225,7 @@ export const createServer = ( binaryHardening, peCertificates, peImports, + windowsShortcut, electronFuses, pythonPyc, webNetworkCapture, @@ -539,6 +541,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerWindowsShortcutTool( + server, + windowsShortcut, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerRecordedCrashTools( server, recordedCrash, diff --git a/src/server/registerWindowsShortcutTool.ts b/src/server/registerWindowsShortcutTool.ts new file mode 100644 index 0000000000..9e58f687b3 --- /dev/null +++ b/src/server/registerWindowsShortcutTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { WindowsShortcutService } from "../application/binaryDiagnostics/WindowsShortcutService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file Shell Link workflow and retain its complete Evidence. */ +export const registerWindowsShortcutTool = ( + server: EvidenceMcpServer, + service: WindowsShortcutService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_windows_shortcut"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 61276ac348..3ebfb3748f 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -247,6 +247,7 @@ const pathRequests: readonly [string, Record][] = [ ["inspect_pe_resources", { path: nul }], ["inspect_pe_certificates", { path: nul }], ["inspect_pe_imports", { path: nul }], + ["inspect_windows_shortcut", { path: nul }], ["inspect_native_exports", { path: nul }], ["inspect_elf_notes", { path: nul }], ["inspect_apple_ips", { path: nul }], diff --git a/tests/acceptance/windowsShortcutCli.test.ts b/tests/acceptance/windowsShortcutCli.test.ts new file mode 100644 index 0000000000..67819b3745 --- /dev/null +++ b/tests/acceptance/windowsShortcutCli.test.ts @@ -0,0 +1,107 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { + powershellHiddenIconSpoofShortcut, + truncatedStringDataShortcut, + wrongClsidShortcut, +} from "../fixtures/binaryDiagnostics/windowsShortcut.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const shortcutContract = (() => { + const contract = BINARY_DIAGNOSTICS_TOOL_CONTRACTS.find( + (item) => item.name === "inspect_windows_shortcut", + ); + if (contract === undefined) + throw new Error("Missing inspect_windows_shortcut contract"); + return contract; +})(); + +cliTest( + "reports Shell Link facts through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-windows-shortcut-cli-"); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + const result = await cli.run({ + arguments: ["inspect-windows-shortcut", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = shortcutContract.outputSchema.parse(result.json); + expect(evidence.normalized_result.header.show_command.decoded).toBe( + "minimized", + ); + expect(evidence.normalized_result.string_data.arguments?.value).toContain( + "-WindowStyle Hidden", + ); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected a stdio MCP session"); + const mcp = await call("inspect_windows_shortcut", { path }); + expect(mcp.isError, JSON.stringify(mcp.content)).not.toBe(true); + const mcpEvidence = shortcutContract.outputSchema.parse( + mcp.structuredContent, + ); + expect(mcpEvidence.normalized_result.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "icon_location_differs_from_target", + value: true, + }), + ]), + ); + }, +); + +cliTest( + "resolves a relative Shell Link path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-windows-shortcut-cli-rel-"); + await writeFile( + join(root, "shortcut.lnk"), + powershellHiddenIconSpoofShortcut(), + ); + const result = await cli.run({ + arguments: ["inspect-windows-shortcut", "shortcut.lnk", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = shortcutContract.outputSchema.parse(result.json); + expect(evidence.subject?.local_path).toBe(join(root, "shortcut.lnk")); + }, +); + +cliTest("reports a wrong CLSID as invalid input", async ({ cli }) => { + const root = await createTestTempDirectory("rea-windows-shortcut-cli-bad-"); + const path = join(root, "broken.lnk"); + await writeFile(path, wrongClsidShortcut()); + const result = await cli.run({ + arguments: ["inspect-windows-shortcut", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_windows_shortcut" }, + }); +}); + +cliTest( + "keeps truncated StringData as a partial inventory", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-windows-shortcut-cli-trunc-", + ); + const path = join(root, "truncated.lnk"); + await writeFile(path, truncatedStringDataShortcut()); + const result = await cli.run({ + arguments: ["inspect-windows-shortcut", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = shortcutContract.outputSchema.parse( + result.json, + ).normalized_result; + expect(report.coverage.status).toBe("partial"); + expect(report.coverage.failing_offset).toEqual(expect.any(Number)); + }, +); diff --git a/tests/boundary/filesystem/windowsShortcut.test.ts b/tests/boundary/filesystem/windowsShortcut.test.ts new file mode 100644 index 0000000000..30d26ec776 --- /dev/null +++ b/tests/boundary/filesystem/windowsShortcut.test.ts @@ -0,0 +1,373 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createWindowsShortcutService } from "../../../src/composition/binaryDiagnostics.js"; +import { WindowsShortcutProvider } from "../../../src/native/windows/WindowsShortcutProvider.js"; +import { + powershellHiddenIconSpoofShortcut, + windowsShortcutBytes, +} from "../../fixtures/binaryDiagnostics/windowsShortcut.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const { realpathCalls, lstatCalls, lstatGate } = vi.hoisted(() => ({ + realpathCalls: vi.fn(), + lstatCalls: vi.fn(), + // When set, the next lstat reports its path and waits for the gate. + lstatGate: {} as { + hold?: + | { entered: (path: string) => void; release: Promise } + | undefined; + }, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpath: (async (...args: Parameters) => { + realpathCalls(...args); + return actual.realpath(...args); + }) as typeof actual.realpath, + lstat: (async (...args: Parameters) => { + lstatCalls(String(args[0])); + const hold = lstatGate.hold; + if (hold !== undefined) { + lstatGate.hold = undefined; + hold.entered(String(args[0])); + await hold.release; + } + return actual.lstat(...args); + }) as typeof actual.lstat, + }; +}); + +// The input schema admits Windows path forms only on a Windows host, so these +// reach the provider directly to run on every host. +const providerInput = (path: string) => ({ + path, + max_file_bytes: 1048576, + max_id_list_items: 256, + max_extra_data_blocks: 64, + max_string_bytes: 1048576, +}); + +describe("Windows shortcut stable filesystem boundary", () => { + it("reports non-file and malformed Shell Link selections as caller input errors", async () => { + const root = await createTestTempDirectory("rea-windows-shortcut-files-"); + const path = join(root, "shortcut.lnk"); + const alias = join(root, "alias.lnk"); + const malformed = join(root, "malformed.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + await writeFile(malformed, Buffer.from("not a shell link")); + await symlink(path, alias); + const service = createWindowsShortcutService(); + for (const [candidate, reason] of [ + [root, "invalid_format"], + [alias, "invalid_format"], + [malformed, "invalid_format"], + [join(root, "missing.lnk"), "invalid_value"], + [join(path, "child.lnk"), "invalid_value"], + ] as const) { + const result = await service.inspect({ path: candidate }); + if (result.ok) throw new Error("Expected file acquisition failure"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_windows_shortcut", + issues: [ + { + path: ["path"], + reason, + message: expect.any(String), + }, + ], + }); + } + }); + + it.skipIf(process.platform === "win32")( + "rejects a symlinked shortcut without resolving its destination", + async () => { + const root = await createTestTempDirectory("rea-windows-shortcut-link-"); + const path = join(root, "shortcut.lnk"); + const alias = join(root, "alias.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + await symlink(path, alias); + realpathCalls.mockClear(); + + const result = await new WindowsShortcutProvider().inspect( + providerInput(alias), + ); + if (result.ok) throw new Error("Expected symlink rejection"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [{ path: ["path"], reason: "invalid_format" }], + }); + expect(realpathCalls).not.toHaveBeenCalled(); + }, + ); + + it("rejects network share paths without opening them", async () => { + const provider = new WindowsShortcutProvider("win32"); + for (const path of [ + "\\\\server\\share\\shortcut.lnk", + "//server/share/shortcut.lnk", + "\\\\?\\UNC\\server\\share\\shortcut.lnk", + "//./UNC/server/share/shortcut.lnk", + "\\\\?\\GLOBALROOT\\Device\\Mup\\server\\share\\shortcut.lnk", + ]) { + const result = await provider.inspect(providerInput(path)); + if (result.ok) throw new Error("Expected network share rejection"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + operation: "inspect_windows_shortcut", + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining("network share"), + }, + ], + }); + } + }); + + it("does not treat an extended drive path as a network share", async () => { + const provider = new WindowsShortcutProvider("win32"); + for (const path of [ + "\\\\?\\C:\\shortcuts\\missing.lnk", + "\\\\.\\C:\\shortcuts\\missing.lnk", + ]) { + const result = await provider.inspect(providerInput(path)); + if (result.ok) throw new Error("Expected a missing file"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["path"], + message: expect.not.stringContaining("network share"), + }, + ], + }); + } + }); + + it.skipIf(process.platform === "win32")( + "reads repeated leading slashes as a local POSIX path", + async () => { + const root = await createTestTempDirectory("rea-windows-shortcut-posix-"); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + + const result = await new WindowsShortcutProvider("linux").inspect( + providerInput(`//${path}`), + ); + expect(result.ok).toBe(true); + }, + ); + + it("keeps a partial report when a LinkInfo offset points past the file", async () => { + const root = await createTestTempDirectory("rea-windows-shortcut-offset-"); + const path = join(root, "shortcut.lnk"); + const bytes = windowsShortcutBytes({ idList: null }); + // LocalBasePathOffset sits 16 bytes into LinkInfo, which follows the header. + bytes.writeUInt32LE(0xffffffff, 76 + 16); + await writeFile(path, bytes); + const result = await createWindowsShortcutService().inspect({ path }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + header: { header_size: 76 }, + coverage: { status: "partial", failing_offset: 92 }, + }); + }); + + it("returns typed resource and cancellation failures", async () => { + const root = await createTestTempDirectory("rea-windows-shortcut-budget-"); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + const service = createWindowsShortcutService(); + const tooSmall = await service.inspect({ path, max_file_bytes: 76 }); + if (tooSmall.ok) throw new Error("Expected resource budget failure"); + expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError"); + const items = await service.inspect({ path, max_id_list_items: 1 }); + if (items.ok) throw new Error("Expected IDList budget failure"); + expect(items.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { boundary: "id_list_items", maximum: 1 }, + remediationAction: expect.stringContaining("max_id_list_items"), + retryable: false, + }); + const cancelled = await service.inspect( + { path }, + { signal: AbortSignal.abort() }, + ); + if (cancelled.ok) throw new Error("Expected cancellation"); + expect(cancelled.error._tag).toBe("AnalysisCancelledError"); + }); +}); + +describe("Windows shortcut parent directory links", () => { + it.skipIf(process.platform === "win32")( + "rejects a parent directory link before reading through it", + async () => { + const root = await createTestTempDirectory( + "rea-windows-shortcut-parent-", + ); + const real = join(root, "real"); + const linked = join(root, "linked"); + await mkdir(real); + await writeFile( + join(real, "shortcut.lnk"), + powershellHiddenIconSpoofShortcut(), + ); + await symlink(real, linked); + const provider = new WindowsShortcutProvider("win32"); + + const result = await provider.inspect( + providerInput(join(linked, "shortcut.lnk")), + ); + if (result.ok) throw new Error("Expected parent link rejection"); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + { + path: ["path"], + reason: "invalid_value", + observed: { linked_ancestor: linked }, + }, + ], + }); + expect( + await provider.inspect(providerInput(join(real, "shortcut.lnk"))), + ).toMatchObject({ ok: true }); + }, + ); + + it.skipIf(process.platform === "win32")( + "admits the parent walk so close waits for it and a later call is refused", + async () => { + const root = await createTestTempDirectory( + "rea-windows-shortcut-parent-close-", + ); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + const provider = new WindowsShortcutProvider("win32"); + let release!: () => void; + const entered = new Promise((resolve) => { + lstatGate.hold = { + entered: resolve, + release: new Promise((done) => { + release = done; + }), + }; + }); + + const inspecting = provider.inspect(providerInput(path)); + let closing: Promise | undefined; + try { + await awaitAcknowledgement( + entered.then(() => undefined), + inspecting, + "parent walk lstat", + ); + let closed = false; + closing = provider.close().then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(closed).toBe(false); + } finally { + lstatGate.hold = undefined; + release(); + await inspecting; + await closing; + } + + const after = await provider.inspect(providerInput(path)); + if (after.ok) throw new Error("Expected a closed provider to refuse"); + expect(after.error._tag).not.toBe("AnalysisInputError"); + }, + ); + + it("checks the caller's signal before walking parents", async () => { + const root = await createTestTempDirectory( + "rea-windows-shortcut-parent-abort-", + ); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + const entered = vi.fn(); + lstatGate.hold = { entered, release: Promise.resolve() }; + + const result = await new WindowsShortcutProvider("win32").inspect( + providerInput(path), + { signal: AbortSignal.abort() }, + ); + lstatGate.hold = undefined; + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(entered).not.toHaveBeenCalled(); + }); + + it.skipIf(process.platform === "win32")( + "walks the parents Windows reaches after removing dot components", + async () => { + const root = await createTestTempDirectory( + "rea-windows-shortcut-parent-dots-", + ); + const real = join(root, "real"); + await mkdir(real); + await writeFile( + join(real, "shortcut.lnk"), + powershellHiddenIconSpoofShortcut(), + ); + // A sibling link, so the POSIX host's own `..` lands where Windows would. + // The paths are spelled out, since `join` would remove the dots itself. + await symlink(real, join(root, "alias")); + const provider = new WindowsShortcutProvider("win32"); + + expect( + await provider.inspect( + providerInput(`${root}/alias/../real/shortcut.lnk`), + ), + ).toMatchObject({ ok: true }); + expect( + await provider.inspect(providerInput(`${root}/./alias/./shortcut.lnk`)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisInputError", + issues: [{ observed: { linked_ancestor: `${root}/alias` } }], + }, + }); + }, + ); + + it("starts the walk at the first parent of the effective Windows path", async () => { + const provider = new WindowsShortcutProvider("win32"); + for (const [path, first] of [ + ["C:\\alias\\..\\real\\a.lnk", "C:\\real"], + ["C:/alias/../real/a.lnk", "C:/real"], + ["C:\\..\\.\\real\\a.lnk", "C:\\real"], + ["\\\\.\\C:\\alias\\..\\real\\a.lnk", "\\\\.\\C:\\real"], + ["\\\\?\\C:\\alias\\..\\real\\a.lnk", "\\\\?\\C:\\alias"], + ["..\\real\\a.lnk", ".."], + ] as const) { + const entered = vi.fn(); + lstatGate.hold = { entered, release: Promise.resolve() }; + await provider.inspect(providerInput(path)); + lstatGate.hold = undefined; + expect(entered).toHaveBeenCalledWith(first); + } + }); +}); + +describe("Windows shortcut deep parent paths", () => { + it("stops a deep missing Windows path at its first unreadable parent", async () => { + const provider = new WindowsShortcutProvider("win32"); + const path = `C:\\missing${"\\a".repeat(8_000)}\\a.lnk`; + lstatCalls.mockClear(); + await provider.inspect(providerInput(path)); + expect(lstatCalls.mock.calls[0]).toEqual(["C:\\missing"]); + expect(lstatCalls).not.toHaveBeenCalledWith("C:\\missing\\a"); + }); +}); diff --git a/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts b/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts index 98b6a9a2a0..1649d538af 100644 --- a/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts +++ b/tests/boundary/mcp/ghidraFailureDiagnostics.test.ts @@ -14,7 +14,7 @@ import { GhidraSessionError, bindGhidraSessionFailure, } from "../../../src/ghidra/GhidraSessionError.js"; -import { err } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { nativeAotPeFixture, nativeAotPeDigest, @@ -25,7 +25,6 @@ import { expect, it } from "vitest"; import { logCliCommand } from "../../../src/cliLogging.js"; import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; -import { ok } from "../../../src/domain/result.js"; import { GhidraRequestQueue } from "../../../src/ghidra/GhidraRequestQueue.js"; import { silentLogger } from "../../../src/logger.js"; import { connectGhidraMcp } from "./ghidraMcpHarness.js"; diff --git a/tests/boundary/mcp/windowsShortcutMcp.test.ts b/tests/boundary/mcp/windowsShortcutMcp.test.ts new file mode 100644 index 0000000000..dc2fbc05b9 --- /dev/null +++ b/tests/boundary/mcp/windowsShortcutMcp.test.ts @@ -0,0 +1,73 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { powershellHiddenIconSpoofShortcut } from "../../fixtures/binaryDiagnostics/windowsShortcut.js"; +import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const shortcutContract = (() => { + const contract = BINARY_DIAGNOSTICS_TOOL_CONTRACTS.find( + (item) => item.name === "inspect_windows_shortcut", + ); + if (contract === undefined) + throw new Error("Missing inspect_windows_shortcut contract"); + return contract; +})(); + +mcpTest( + "advertises inspect_windows_shortcut and reads Shell Link facts", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("Shell Link inspection must not start a deep provider"); + }); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + try { + const { + toolDefinition: advertised, + ajv, + outputSchema, + } = await advertisedMcpToolSchemas(client, "inspect_windows_shortcut"); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-windows-shortcut-mcp-"); + const path = join(root, "shortcut.lnk"); + await writeFile(path, powershellHiddenIconSpoofShortcut()); + const response = await client.callTool({ + name: "inspect_windows_shortcut", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const evidence = shortcutContract.outputSchema.parse( + response.structuredContent, + ); + expect(evidence.normalized_result.header.clsid).toBe( + "00021401-0000-0000-C000-000000000046", + ); + expect(evidence.normalized_result.coverage.status).toBe("complete"); + const relative = await client.callTool({ + name: "inspect_windows_shortcut", + arguments: { path: "shortcut.lnk" }, + }); + expect(relative.isError).toBe(true); + expect(parseMcpToolError(relative).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_windows_shortcut" }, + }); + } finally { + await session.close(); + } + }, +); diff --git a/tests/boundary/providers/nativeArtifactFinalization.test.ts b/tests/boundary/providers/nativeArtifactFinalization.test.ts index 0ae55f617a..c9e11768c8 100644 --- a/tests/boundary/providers/nativeArtifactFinalization.test.ts +++ b/tests/boundary/providers/nativeArtifactFinalization.test.ts @@ -11,6 +11,7 @@ import { inspectElfNotesInputSchema } from "../../../src/domain/native/elfNotes. import { inspectKernelModuleInputSchema } from "../../../src/domain/native/kernelModule.js"; import { inspectNativeExportsInputSchema } from "../../../src/domain/native/nativeExports.js"; import { inspectAppleIpsInputSchema } from "../../../src/domain/native/appleIps.js"; +import { inspectWindowsShortcutInputSchema } from "../../../src/domain/native/windowsShortcut.js"; import { inspectApkSignatureInputSchema } from "../../../src/domain/apkSignature/apkSignatureQuery.js"; import { PeResourcesProvider } from "../../../src/native/pe/PeResourcesProvider.js"; import { PeCertificatesProvider } from "../../../src/native/pe/PeCertificatesProvider.js"; @@ -19,6 +20,7 @@ import { ElfNotesProvider } from "../../../src/native/elf/ElfNotesProvider.js"; import { KernelModuleProvider } from "../../../src/native/elf/KernelModuleProvider.js"; import { NativeExportsProvider } from "../../../src/native/exports/NativeExportsProvider.js"; import { AppleIpsProvider } from "../../../src/native/apple/AppleIpsProvider.js"; +import { WindowsShortcutProvider } from "../../../src/native/windows/WindowsShortcutProvider.js"; import { ApkSignatureProvider } from "../../../src/apkSignature/ApkSignatureProvider.js"; import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -115,6 +117,19 @@ const cases = [ }; }, }, + { + name: "Windows shortcut", + make: () => { + const provider = new WindowsShortcutProvider(); + return { + inspect: (path: string, signal: AbortSignal) => + provider.inspect(inspectWindowsShortcutInputSchema.parse({ path }), { + signal, + }), + close: () => provider.close(), + }; + }, + }, { name: "APK signatures", make: () => { diff --git a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts new file mode 100644 index 0000000000..97f13253b4 --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts @@ -0,0 +1,593 @@ +/** Synthesize Shell Link bytes for parser, CLI, and MCP tests. */ + +export const SHELL_LINK_CLSID = Buffer.from([ + 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x46, +]); + +export const MY_COMPUTER_CLSID = Buffer.from([ + 0xe0, 0x4f, 0xd0, 0x20, 0xea, 0x3a, 0x69, 0x10, 0xa2, 0xd8, 0x08, 0x00, 0x2b, + 0x30, 0x30, 0x9d, +]); + +/** 2024-01-15T12:00:00.000Z as FILETIME 100-nanosecond intervals. */ +export const FIXTURE_FILETIME = 133497936000000000n; + +export const LINK_FLAG = { + HasLinkTargetIDList: 0x00000001, + HasLinkInfo: 0x00000002, + HasName: 0x00000004, + HasRelativePath: 0x00000008, + HasWorkingDir: 0x00000010, + HasArguments: 0x00000020, + HasIconLocation: 0x00000040, + IsUnicode: 0x00000080, + HasExpString: 0x00000200, + HasExpIcon: 0x00004000, +} as const; + +export const EXTRA_SIGNATURE = { + EnvironmentVariable: 0xa0000001, + Tracker: 0xa0000003, + ConsoleFE: 0xa0000004, + SpecialFolder: 0xa0000005, + IconEnvironment: 0xa0000007, + PropertyStore: 0xa0000009, + KnownFolder: 0xa000000b, +} as const; + +export const TRACKER_MAC = Buffer.from([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]); +/** UUID timestamp 100ns since 1582-10-15 matching 2024-01-15T12:00:00.000Z. */ +export const TRACKER_UUID_TIMESTAMP = 139289664000000000n; + +export type FixtureIdListItem = + | { + readonly kind: "root"; + readonly sortIndex?: number; + readonly folderId?: Buffer; + } + | { readonly kind: "volume"; readonly name: string } + | { + readonly kind: "file"; + readonly classType?: number; + readonly fileSize?: number; + readonly shortName: string; + } + | { readonly kind: "raw"; readonly data: Buffer }; + +export type FixtureLinkInfo = { + readonly driveType?: number; + readonly serial?: number; + readonly label?: string; + /** Bytes between the 16-byte header and an ANSI label. */ + readonly volumeLabelGap?: Buffer; + readonly localBasePath?: string; + readonly commonPathSuffix?: string; + readonly netName?: string; + readonly deviceName?: string; + readonly networkProviderType?: number; + readonly networkFlags?: number; +}; + +export type FixtureExtraBlock = + | { + readonly kind: "environment"; + readonly ansi?: string; + readonly unicode?: string; + } + | { + readonly kind: "icon_environment"; + readonly ansi?: string; + readonly unicode?: string; + } + | { + readonly kind: "tracker"; + readonly length?: number; + readonly version?: number; + readonly machineId?: string; + readonly droid?: readonly [Buffer, Buffer]; + readonly birthDroid?: readonly [Buffer, Buffer]; + } + | { + readonly kind: "known_folder"; + readonly folderId?: Buffer; + readonly itemOffset?: number; + } + | { + readonly kind: "special_folder"; + readonly folderId?: number; + readonly itemOffset?: number; + } + | { readonly kind: "console_fe"; readonly codePage?: number } + | { readonly kind: "property_store"; readonly payload?: Buffer } + | { + readonly kind: "unknown"; + readonly signature: number; + readonly payload?: Buffer; + }; + +export type WindowsShortcutFixtureOptions = { + readonly headerSize?: number; + readonly clsid?: Buffer; + readonly flags?: number; + readonly fileAttributes?: number; + readonly creationTime?: bigint; + readonly accessTime?: bigint; + readonly writeTime?: bigint; + readonly fileSize?: number; + readonly iconIndex?: number; + readonly showCommand?: number; + readonly hotKey?: number; + readonly unicode?: boolean; + readonly idList?: readonly FixtureIdListItem[] | null; + readonly linkInfo?: FixtureLinkInfo | null; + readonly name?: string; + readonly relativePath?: string; + readonly workingDir?: string; + readonly arguments?: string; + readonly iconLocation?: string; + readonly extraData?: readonly FixtureExtraBlock[]; + readonly terminalBlock?: boolean; + readonly truncateAt?: number; + readonly omitStringBytes?: number; +}; + +const ansiZ = (value: string): Buffer => + Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]); + +const utf16z = (value: string): Buffer => + Buffer.concat([Buffer.from(value, "utf16le"), Buffer.alloc(2)]); + +const padField = ( + value: string, + bytes: number, + encoding: "latin1" | "utf16le", +) => { + const field = Buffer.alloc(bytes); + Buffer.from(value, encoding).copy(field); + return field; +}; + +const writeU32 = (target: Buffer, offset: number, value: number): void => { + target.writeUInt32LE(value >>> 0, offset); +}; + +const writeU64 = (target: Buffer, offset: number, value: bigint): void => { + target.writeBigUInt64LE(value, offset); +}; + +/** Windows GUID bytes for a version-1 UUID with the supplied node and timestamp. */ +export const uuidV1Bytes = ( + timestamp100ns: bigint, + clockSeq: number, + node: Buffer, +): Buffer => { + const bytes = Buffer.alloc(16); + bytes.writeUInt32LE(Number(timestamp100ns & 0xffffffffn), 0); + bytes.writeUInt16LE(Number((timestamp100ns >> 32n) & 0xffffn), 4); + bytes.writeUInt16LE(Number((timestamp100ns >> 48n) & 0x0fffn) | 0x1000, 6); + bytes[8] = ((clockSeq >> 8) & 0x3f) | 0x80; + bytes[9] = clockSeq & 0xff; + node.copy(bytes, 10, 0, 6); + return bytes; +}; + +const defaultTrackerGuid = (clockSeq: number): Buffer => + uuidV1Bytes(TRACKER_UUID_TIMESTAMP, clockSeq, TRACKER_MAC); + +const itemBytes = (item: FixtureIdListItem): Buffer => { + if (item.kind === "raw") { + const size = Buffer.alloc(2); + size.writeUInt16LE(item.data.length + 2, 0); + return Buffer.concat([size, item.data]); + } + if (item.kind === "root") { + const data = Buffer.alloc(18); + data[0] = 0x1f; + data[1] = item.sortIndex ?? 0x50; + (item.folderId ?? MY_COMPUTER_CLSID).copy(data, 2); + const size = Buffer.alloc(2); + size.writeUInt16LE(20, 0); + return Buffer.concat([size, data]); + } + if (item.kind === "volume") { + const name = Buffer.from(`${item.name}\0`, "latin1"); + const data = Buffer.concat([Buffer.from([0x2f]), name]); + const size = Buffer.alloc(2); + size.writeUInt16LE(data.length + 2, 0); + return Buffer.concat([size, data]); + } + const shortName = Buffer.from(`${item.shortName}\0`, "latin1"); + const padded = + shortName.length % 2 === 0 + ? shortName + : Buffer.concat([shortName, Buffer.from([0])]); + const data = Buffer.alloc(12 + padded.length); + data[0] = item.classType ?? 0x32; + data.writeUInt32LE(item.fileSize ?? 0, 2); + padded.copy(data, 12); + const size = Buffer.alloc(2); + size.writeUInt16LE(data.length + 2, 0); + return Buffer.concat([size, data]); +}; + +const idListBytes = (items: readonly FixtureIdListItem[]): Buffer => { + const body = Buffer.concat([...items.map(itemBytes), Buffer.alloc(2)]); + const size = Buffer.alloc(2); + size.writeUInt16LE(body.length, 0); + return Buffer.concat([size, body]); +}; + +const volumeIdBytes = (info: FixtureLinkInfo, unicode: boolean): Buffer => { + const label = unicode + ? utf16z(info.label ?? "VOL") + : ansiZ(info.label ?? "VOL"); + const gap = info.volumeLabelGap ?? Buffer.alloc(0); + const header = unicode ? 20 : 16 + gap.length; + const bytes = Buffer.alloc(header + label.length); + writeU32(bytes, 0, bytes.length); + writeU32(bytes, 4, info.driveType ?? 3); + writeU32(bytes, 8, info.serial ?? 0x1234abcd); + writeU32(bytes, 12, unicode ? 0x14 : header); + if (unicode) writeU32(bytes, 16, header); + else gap.copy(bytes, 16); + label.copy(bytes, header); + return bytes; +}; + +const networkLinkBytes = (info: FixtureLinkInfo, unicode: boolean): Buffer => { + const netAnsi = ansiZ(info.netName ?? "\\\\server\\share"); + const deviceAnsi = ansiZ(info.deviceName ?? "Z:"); + const netWide = unicode + ? utf16z(info.netName ?? "\\\\server\\share") + : Buffer.alloc(0); + const deviceWide = unicode + ? utf16z(info.deviceName ?? "Z:") + : Buffer.alloc(0); + const header = unicode ? 0x1c : 0x14; + const deviceAnsiOffset = header + netAnsi.length; + const netWideOffset = deviceAnsiOffset + deviceAnsi.length; + const deviceWideOffset = netWideOffset + netWide.length; + const bytes = Buffer.alloc(deviceWideOffset + deviceWide.length); + writeU32(bytes, 0, bytes.length); + writeU32(bytes, 4, info.networkFlags ?? 0x3); + writeU32(bytes, 8, header); + writeU32(bytes, 12, deviceAnsiOffset); + writeU32(bytes, 16, info.networkProviderType ?? 0x00020000); + if (unicode) { + writeU32(bytes, 20, netWideOffset); + writeU32(bytes, 24, deviceWideOffset); + } + netAnsi.copy(bytes, header); + deviceAnsi.copy(bytes, deviceAnsiOffset); + netWide.copy(bytes, netWideOffset); + deviceWide.copy(bytes, deviceWideOffset); + return bytes; +}; + +const linkInfoBytes = (info: FixtureLinkInfo, unicode: boolean): Buffer => { + const hasVolume = + info.localBasePath !== undefined || info.label !== undefined; + const hasNetwork = + info.netName !== undefined || info.deviceName !== undefined; + const flags = (hasVolume ? 0x1 : 0) | (hasNetwork ? 0x2 : 0) || 0x1; + const headerSize = unicode ? 0x24 : 0x1c; + const volume = flags & 0x1 ? volumeIdBytes(info, unicode) : Buffer.alloc(0); + const localAnsi = + flags & 0x1 + ? ansiZ(info.localBasePath ?? "C:\\target.exe") + : Buffer.alloc(0); + const network = + flags & 0x2 ? networkLinkBytes(info, unicode) : Buffer.alloc(0); + const suffixAnsi = ansiZ(info.commonPathSuffix ?? ""); + const localWide = + unicode && flags & 0x1 + ? utf16z(info.localBasePath ?? "C:\\target.exe") + : Buffer.alloc(0); + const suffixWide = unicode + ? utf16z(info.commonPathSuffix ?? "") + : Buffer.alloc(0); + let cursor = headerSize; + const volumeOffset = flags & 0x1 ? cursor : 0; + if (flags & 0x1) cursor += volume.length; + const localOffset = flags & 0x1 ? cursor : 0; + if (flags & 0x1) cursor += localAnsi.length; + const networkOffset = flags & 0x2 ? cursor : 0; + if (flags & 0x2) cursor += network.length; + const suffixOffset = cursor; + cursor += suffixAnsi.length; + const localWideOffset = unicode && flags & 0x1 ? cursor : 0; + if (unicode && flags & 0x1) cursor += localWide.length; + const suffixWideOffset = unicode ? cursor : 0; + if (unicode) cursor += suffixWide.length; + const bytes = Buffer.alloc(cursor); + writeU32(bytes, 0, cursor); + writeU32(bytes, 4, headerSize); + writeU32(bytes, 8, flags); + writeU32(bytes, 12, volumeOffset); + writeU32(bytes, 16, localOffset); + writeU32(bytes, 20, networkOffset); + writeU32(bytes, 24, suffixOffset); + if (unicode) { + writeU32(bytes, 28, localWideOffset); + writeU32(bytes, 32, suffixWideOffset); + } + if (volumeOffset > 0) volume.copy(bytes, volumeOffset); + if (localOffset > 0) localAnsi.copy(bytes, localOffset); + if (networkOffset > 0) network.copy(bytes, networkOffset); + suffixAnsi.copy(bytes, suffixOffset); + if (localWideOffset > 0) localWide.copy(bytes, localWideOffset); + if (suffixWideOffset > 0) suffixWide.copy(bytes, suffixWideOffset); + return bytes; +}; + +/** StringData is counted, not NUL-terminated (MS-SHLLINK 2.4). */ +const stringDataField = (value: string, unicode: boolean): Buffer => { + const text = Buffer.from(value, unicode ? "utf16le" : "latin1"); + const count = Buffer.alloc(2); + count.writeUInt16LE(value.length, 0); + return Buffer.concat([count, text]); +}; + +const environmentBlock = ( + signature: number, + ansi: string, + unicode: string, +): Buffer => { + const bytes = Buffer.alloc(0x314); + writeU32(bytes, 0, 0x314); + writeU32(bytes, 4, signature); + padField(ansi, 260, "latin1").copy(bytes, 8); + padField(unicode, 520, "utf16le").copy(bytes, 268); + return bytes; +}; + +const extraBlockBytes = (block: FixtureExtraBlock): Buffer => { + if (block.kind === "environment") + return environmentBlock( + EXTRA_SIGNATURE.EnvironmentVariable, + block.ansi ?? + "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + block.unicode ?? + "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + if (block.kind === "icon_environment") + return environmentBlock( + EXTRA_SIGNATURE.IconEnvironment, + block.ansi ?? "%SystemRoot%\\System32\\shell32.dll", + block.unicode ?? "%SystemRoot%\\System32\\shell32.dll", + ); + if (block.kind === "tracker") { + const bytes = Buffer.alloc(0x60); + writeU32(bytes, 0, 0x60); + writeU32(bytes, 4, EXTRA_SIGNATURE.Tracker); + writeU32(bytes, 8, block.length ?? 0x58); + writeU32(bytes, 12, block.version ?? 0); + padField(block.machineId ?? "WORKSTATION", 16, "latin1").copy(bytes, 16); + const droid0 = block.droid?.[0] ?? defaultTrackerGuid(1); + const droid1 = block.droid?.[1] ?? defaultTrackerGuid(2); + const birth0 = block.birthDroid?.[0] ?? defaultTrackerGuid(3); + const birth1 = block.birthDroid?.[1] ?? defaultTrackerGuid(4); + droid0.copy(bytes, 32); + droid1.copy(bytes, 48); + birth0.copy(bytes, 64); + birth1.copy(bytes, 80); + return bytes; + } + if (block.kind === "known_folder") { + const bytes = Buffer.alloc(0x1c); + writeU32(bytes, 0, 0x1c); + writeU32(bytes, 4, EXTRA_SIGNATURE.KnownFolder); + (block.folderId ?? MY_COMPUTER_CLSID).copy(bytes, 8); + writeU32(bytes, 24, block.itemOffset ?? 0); + return bytes; + } + if (block.kind === "special_folder") { + const bytes = Buffer.alloc(0x10); + writeU32(bytes, 0, 0x10); + writeU32(bytes, 4, EXTRA_SIGNATURE.SpecialFolder); + writeU32(bytes, 8, block.folderId ?? 0x24); + writeU32(bytes, 12, block.itemOffset ?? 0); + return bytes; + } + if (block.kind === "console_fe") { + const bytes = Buffer.alloc(0x0c); + writeU32(bytes, 0, 0x0c); + writeU32(bytes, 4, EXTRA_SIGNATURE.ConsoleFE); + writeU32(bytes, 8, block.codePage ?? 437); + return bytes; + } + if (block.kind === "property_store") { + const payload = block.payload ?? Buffer.alloc(4); + const bytes = Buffer.alloc(8 + payload.length); + writeU32(bytes, 0, bytes.length); + writeU32(bytes, 4, EXTRA_SIGNATURE.PropertyStore); + payload.copy(bytes, 8); + return bytes; + } + const payload = block.payload ?? Buffer.from([0xde, 0xad]); + const bytes = Buffer.alloc(8 + payload.length); + writeU32(bytes, 0, bytes.length); + writeU32(bytes, 4, block.signature); + payload.copy(bytes, 8); + return bytes; +}; + +const defaultIdList = (): readonly FixtureIdListItem[] => [ + { kind: "root" }, + { kind: "volume", name: "C:\\" }, + { + kind: "file", + shortName: "POWERS~1.EXE", + fileSize: 450560, + classType: 0x32, + }, +]; + +const derivedFlags = ( + options: WindowsShortcutFixtureOptions, + unicode: boolean, +): number => { + if (options.flags !== undefined) return options.flags; + let flags = 0; + if (options.idList !== null) flags |= LINK_FLAG.HasLinkTargetIDList; + if (options.linkInfo !== null) flags |= LINK_FLAG.HasLinkInfo; + if (options.name !== undefined) flags |= LINK_FLAG.HasName; + if (options.relativePath !== undefined) flags |= LINK_FLAG.HasRelativePath; + if (options.workingDir !== undefined) flags |= LINK_FLAG.HasWorkingDir; + if (options.arguments !== undefined) flags |= LINK_FLAG.HasArguments; + if (options.iconLocation !== undefined) flags |= LINK_FLAG.HasIconLocation; + if (unicode) flags |= LINK_FLAG.IsUnicode; + const extra = options.extraData ?? []; + if (extra.some((block) => block.kind === "environment")) + flags |= LINK_FLAG.HasExpString; + if (extra.some((block) => block.kind === "icon_environment")) + flags |= LINK_FLAG.HasExpIcon; + return flags; +}; + +/** Build one Shell Link. Omitted options produce a unicode PowerShell shortcut. */ +export const windowsShortcutBytes = ( + options: WindowsShortcutFixtureOptions = {}, +): Buffer => { + const unicode = options.unicode ?? true; + const name = options.name ?? "Windows PowerShell"; + const relativePath = + options.relativePath ?? ".\\WindowsPowerShell\\v1.0\\powershell.exe"; + const workingDir = options.workingDir ?? "C:\\Windows\\System32"; + const argumentText = + options.arguments ?? + '-WindowStyle Hidden -NoProfile -Command "Get-Process"'; + const iconLocation = + options.iconLocation ?? "C:\\Windows\\System32\\shell32.dll"; + const idList = + options.idList === undefined ? defaultIdList() : options.idList; + const linkInfo = + options.linkInfo === undefined + ? { + driveType: 3, + serial: 0x9a8b7c6d, + label: "OS", + localBasePath: + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + commonPathSuffix: "", + } + : options.linkInfo; + const extraData = + options.extraData === undefined + ? ([ + { kind: "environment" }, + { kind: "icon_environment" }, + { kind: "tracker" }, + { kind: "console_fe", codePage: 437 }, + { kind: "special_folder", folderId: 0x24 }, + { kind: "known_folder" }, + { kind: "property_store" }, + ] satisfies FixtureExtraBlock[]) + : options.extraData; + const flags = derivedFlags( + { + ...options, + name, + relativePath, + workingDir, + arguments: argumentText, + iconLocation, + }, + unicode, + ); + const header = Buffer.alloc(76); + writeU32(header, 0, options.headerSize ?? 0x4c); + (options.clsid ?? SHELL_LINK_CLSID).copy(header, 4); + writeU32(header, 20, flags); + writeU32(header, 24, options.fileAttributes ?? 0x20); + writeU64(header, 28, options.creationTime ?? FIXTURE_FILETIME); + writeU64(header, 36, options.accessTime ?? FIXTURE_FILETIME); + writeU64(header, 44, options.writeTime ?? FIXTURE_FILETIME); + writeU32(header, 52, options.fileSize ?? 450560); + header.writeInt32LE(options.iconIndex ?? 0, 56); + writeU32(header, 60, options.showCommand ?? 7); + header.writeUInt16LE(options.hotKey ?? 0, 64); + const parts: Buffer[] = [header]; + if (flags & LINK_FLAG.HasLinkTargetIDList && idList !== null) + parts.push(idListBytes(idList)); + if (flags & LINK_FLAG.HasLinkInfo && linkInfo !== null) + parts.push(linkInfoBytes(linkInfo, unicode)); + const strings: Buffer[] = []; + if (flags & LINK_FLAG.HasName) strings.push(stringDataField(name, unicode)); + if (flags & LINK_FLAG.HasRelativePath) + strings.push(stringDataField(relativePath, unicode)); + if (flags & LINK_FLAG.HasWorkingDir) + strings.push(stringDataField(workingDir, unicode)); + if (flags & LINK_FLAG.HasArguments) + strings.push(stringDataField(argumentText, unicode)); + if (flags & LINK_FLAG.HasIconLocation) + strings.push(stringDataField(iconLocation, unicode)); + let stringBytes = Buffer.concat(strings); + if (options.omitStringBytes !== undefined) + stringBytes = stringBytes.subarray( + 0, + Math.max(0, stringBytes.length - options.omitStringBytes), + ); + parts.push(stringBytes); + for (const block of extraData) parts.push(extraBlockBytes(block)); + if (options.terminalBlock !== false) parts.push(Buffer.alloc(4)); + const bytes = Buffer.concat(parts); + return options.truncateAt === undefined + ? bytes + : bytes.subarray(0, options.truncateAt); +}; + +export const powershellHiddenIconSpoofShortcut = (): Buffer => + windowsShortcutBytes(); + +export const networkShareShortcut = (): Buffer => + windowsShortcutBytes({ + showCommand: 1, + flags: + LINK_FLAG.HasLinkTargetIDList | + LINK_FLAG.HasLinkInfo | + LINK_FLAG.HasName | + LINK_FLAG.HasRelativePath | + LINK_FLAG.IsUnicode, + name: "Share", + relativePath: ".\\report.docx", + linkInfo: { + netName: "\\\\fileserver\\docs", + deviceName: "Z:", + networkProviderType: 0x00020000, + commonPathSuffix: "report.docx", + }, + extraData: [], + }); + +export const trackerV1Shortcut = (): Buffer => + windowsShortcutBytes({ + extraData: [{ kind: "tracker" }], + }); + +export const ansiShortcut = (): Buffer => + windowsShortcutBytes({ + unicode: false, + extraData: [{ kind: "environment", ansi: "C:\\Windows\\notepad.exe" }], + }); + +export const truncatedStringDataShortcut = (): Buffer => + windowsShortcutBytes({ + extraData: [], + terminalBlock: false, + omitStringBytes: 8, + }); + +export const unknownExtraDataShortcut = (): Buffer => + windowsShortcutBytes({ + extraData: [ + { kind: "unknown", signature: 0xa0ffffff, payload: Buffer.from("xx") }, + ], + }); + +export const wrongClsidShortcut = (): Buffer => + windowsShortcutBytes({ + clsid: Buffer.alloc(16), + extraData: [], + }); diff --git a/tests/process-global/stableArtifactConsumers.test.ts b/tests/process-global/stableArtifactConsumers.test.ts index 4a14bb03c4..866ab5af1f 100644 --- a/tests/process-global/stableArtifactConsumers.test.ts +++ b/tests/process-global/stableArtifactConsumers.test.ts @@ -115,6 +115,7 @@ import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js"; import { KernelModuleProvider } from "../../src/native/elf/KernelModuleProvider.js"; import { PeCertificatesProvider } from "../../src/native/pe/PeCertificatesProvider.js"; import { PeImportsProvider } from "../../src/native/pe/PeImportsProvider.js"; +import { WindowsShortcutProvider } from "../../src/native/windows/WindowsShortcutProvider.js"; import { NativeExportsProvider } from "../../src/native/exports/NativeExportsProvider.js"; import { BeamProvider } from "../../src/erlang/BeamProvider.js"; import { inspectElfNotesInputSchema } from "../../src/domain/native/elfNotes.js"; @@ -122,6 +123,7 @@ import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js" import { inspectKernelModuleInputSchema } from "../../src/domain/native/kernelModule.js"; import { inspectPeCertificatesInputSchema } from "../../src/domain/native/peCertificates.js"; import { inspectPeImportsInputSchema } from "../../src/domain/native/peImports.js"; +import { inspectWindowsShortcutInputSchema } from "../../src/domain/native/windowsShortcut.js"; import { inspectNativeExportsInputSchema } from "../../src/domain/native/nativeExports.js"; import { inspectErlangBeamInputSchema } from "../../src/domain/erlang/beam.js"; import { ModelArtifactProvider } from "../../src/model/ModelArtifactProvider.js"; @@ -1004,6 +1006,7 @@ const stableArtifactProviders = [ ["PE load configuration", () => new PeLoadConfigProvider()], ["binary hardening", () => new BinaryHardeningProvider()], ["PE imports", () => new PeImportsProvider()], + ["Windows shortcut", () => new WindowsShortcutProvider()], ["native exports", () => new NativeExportsProvider()], ["Python pyc", () => new PythonPycProvider()], ] as const; @@ -1059,6 +1062,11 @@ const inspectFileFailure = ( inspectPeImportsInputSchema.parse({ path }), options, ); + if (provider instanceof WindowsShortcutProvider) + return provider.inspect( + inspectWindowsShortcutInputSchema.parse({ path }), + options, + ); if (provider instanceof NativeExportsProvider) return provider.inspect( inspectNativeExportsInputSchema.parse({ path }),