mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* docs(windows): document the EDR signal surface Six Microsoft Defender for Endpoint incidents fired against Orca 1.4.192 in eight days on one enterprise Windows 11 / Intune tenant. All six were behavioural process-tree scoring, not signature hits; two escalated to multi-stage incidents mapped to ATT&CK Execution and Collection. Add a reference doc mapping each attack-technique-shaped behaviour to the code that produces it and to why it exists: the renamed daemon image (T1036), the per-process PEB read, encoded policy-bypassed PowerShell (T1049), caret-escaped cmd.exe lines, and computer-use screen capture plus runtime-compiled MSIL (T1113). Records that signing is not the gate -- reputation is signer plus hash-keyed prevalence -- and carries the two evidence gaps the report noted. Adds an engineer checklist, deployment guidance for admins (AV path exclusions do not suppress EDR behavioural alerts; an MDE alert suppression rule does), and an explicit pre-deployment warning about computer use. * docs(windows): correct the PowerShell flag inventory and admin paths Review corrections to the EDR posture doc. The "encoded, policy-bypassing PowerShell" list conflated three different shapes and was incomplete. Split it into the three tiers an EDR actually scores differently -- bypass plus encoding, encoding alone, and bypass alone -- and add the sites it missed, including windows-mobile-firewall.ts, which encodes a script and launches it elevated through Start-Process -Verb RunAs. system-fonts.ts (-Command) and desktop-script-provider-bridge.ts (-File) were listed as encoded and are not. Notes that a raw grep under-reports, because the hook sites reach -EncodedCommand through wrapWindowsPowerShellEncodedCommand. Attribute the in-payload Set-ExecutionPolicy move to #16576 rather than to #16003's measurement, which keyed on -WindowStyle Hidden + -EncodedCommand, and record that the launcher's own tradeoff is unverified on a real box. Admin guidance was missing two ways a suppression rule pinned to one full path misses real activity: the .staging-<hex> sibling that exists mid-update, which is when the update-cluster incidents fire, and the userData fallback when LOCALAPPDATA is unset. Also: state the measurement conditions on the process-table timings, note that Hermes has surface even though we have no telemetry for it, note that the uninstaller names are electron-builder-generated and in no repo file, drop a volatile line count, and mark the per-operation computer-use shape as being addressed by an unmerged change. Drops the duplicated AGENTS.md section, keeping the indexed bullet. * docs(windows): reconcile the EDR posture doc with the shipped remediation Three claims in this doc became false once the rest of the Windows EDR set landed, and two told engineers the opposite of what the release does. The process-table section still described one shared snapshot taken with `Memory | CommandLine | CreationTime`, argued that splitting the cache per field set "would restore exactly the fan-out it exists to prevent", and concluded the shape was unfixable because "the information is only in the PEB". The split shipped (identity opens no handle at all), `Memory` is retired, and the command line now comes from the kernel through `ProcessCommandLineInformation` -- `ReadProcessMemory` is absent from the compiled addon and a ratchet asserts it against the import table. An engineer reading the old text would have concluded both fixes were dead ends. The PowerShell site inventories were stale in three of four lists: the port scan went native, every `-ExecutionPolicy Bypass` + `-EncodedCommand` pair was dropped as a measured no-op, and of the unencoded-bypass list only `wsl-cli-scripts.ts` survives. Regenerated against the merged tree, including the sites that reach the flag through `wrapWindowsPowerShellEncodedCommand` and never spell it, which a raw `rg` misses. Incident-evidence sections are left alone: they record what the tenant observed on 1.4.192, not what the code does now. * fix(windows): copy the daemon host exe verbatim instead of renaming it Microsoft Defender for Endpoint flagged `orca-terminal-daemon.exe` as MITRE T1036 (Masquerading): Orca copied its own `Orca.exe` into %LOCALAPPDATA% under a different name, specifically so the NSIS updater's `taskkill /IM Orca.exe` could not match, then ran it detached. Because that process is what every other flagged action was attributed to, the name mismatch acted as a reputation multiplier on unrelated findings. The rename was never what made the daemon survive. In app-builder-lib 26.15.3 the installer's FIND_PROCESS/KILL_PROCESS select processes whose image path is under $INSTDIR; `taskkill /IM` is only the fallback for hosts where PowerShell is missing or blocked. Survival is a property of the path, and %LOCALAPPDATA%\Orca\daemon-host is outside $INSTDIR whatever the file is called. Derive the host exe name from process.execPath so the copy is byte-for-byte, name included — it keeps its Authenticode signature and carries no renamed-image signal. On the no-PowerShell fallback the daemon is now killed with the app and terminals cold-restore, which is the documented pre-relocation outcome the update harness already asserts, not a regression. The uninstall macro no longer needs a distinct name to find the daemon; it kills the app's own image name (plus the legacy name, for hosts left by older builds). Adds docs/reference/windows-daemon-host-relocation.md with the survival contract, the rejected alternatives and their measured costs, and the invariants to keep. * fix(windows): apply daemon-host relocation review corrections Scope the uninstall taskkill to the current user with `/FI "USERNAME eq %USERNAME%"` via cmd.exe, matching upstream's per-user KILL_PROCESS — without it an elevated machine-wide uninstall reaches another logged-on user's session, so the "no collateral" claim in the comment was overstated. Comment the rmSync-before-publish: Windows refuses to delete a running image, so a live daemon already hosted in this version's dir (same-version reinstall, or a dev channel reusing a version) throws and materialization fails open. Doc corrections: - The fallback selector is the full per-user `taskkill /F /IM "<app>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%"`, not a bare `taskkill /IM`. - The probe reads `Get-ExecutionPolicy -Scope Process`, not the effective policy, and GPO writes MachinePolicy/UserPolicy — so GPO-managed hosts take the primary path-scoped branch. Narrow the fallback triggers accordingly. - Drop the Authenticode sentence: the old name was equally byte-identical and equally signed, so a filename has no bearing on signature validity. - Name the new update-abort path: the daemon now matches FIND_PROCESS, so on the fallback branch an unkillable host reaches the retry loop's MessageBox /SD IDCANCEL and Quits, aborting a silent update. - Correct the customCheckAppRunning rejection. It is ~6 lines, not a rewrite; it is wrong because forcing the PowerShell branch where PowerShell is absent makes FIND/KILL silently no-op and leaves the real app running with files in use. - Bound the win honestly: OriginalFilename is empty on the shipped binary, so the strongest T1036 indicator never fired, and the residual copy-and-run-detached shape still maps to T1036.005. Reconcile docs/reference/windows-edr-posture.md, which documents the rename as a live finding and would otherwise contradict this change. Content-only edit: markdown under docs/reference/ is not oxfmt-formatted as a matter of practice and nothing in CI gates it, so the file is left consistent with its neighbours. * fix(windows): expand USERNAME in NSIS instead of spawning cmd.exe The uninstall macro routed both taskkills through `"$SYSDIR\cmd.exe" /C` purely so `%USERNAME%` would expand — two extra interpreter spawns on the uninstall path, in a change whose whole point is not adding scored behaviour, and the exact `cmd.exe /c` shape the new AGENTS.md EDR bullet warns about. NSIS reads the variable itself with ReadEnvStr, so the spawns buy nothing. Verified on Windows 11 that the generated command line does what the filter is there for: a copy of cmd.exe running as orca-nonexistent-probe.exe (pid 34244) was terminated by `taskkill /F /IM "orca-nonexistent-probe.exe" /FI "USERNAME eq <user>"` — SUCCESS, exit 0, process gone. Guarded on an empty USERNAME because the degenerate case is silent: taskkill rejects an empty filter value outright ("The search filter cannot be recognized") and kills nothing, which would leave exactly the orphaned daemon this macro exists to reap. `*` is rejected as a filter value too, so there is no branchless spelling. With no USERNAME to scope by it kills unfiltered, as the macro did before the filter was added. Stack stays balanced: three pushes, two nsExec pops, three restores. Also strike the last stale row in windows-edr-posture.md's remediation table. "Copying our own image under a different name" read as outstanding work; it is done by this change, so the row now points at the relocation doc. Same class of staleness as the section reconciled in the previous commit, and git would not have flagged it either. * fix(windows): port the daemon-host uninstall sweep into the live NSIS include The uninstall macro this branch rewrote lived in config/nsis/daemon-host-uninstall.nsh, which main no longer includes: #17906 consolidated every Windows installer hook into config/nsis/orca-installer-hooks.nsh because electron-builder accepts exactly one `nsis.include`. Merged as-is, the rewritten macro would have been dead code while the shipped uninstaller kept running main's stale sweep — `taskkill /F /IM orca-terminal-daemon.exe`, which matches nothing now that the relocated host is a verbatim Orca.exe copy. The RMDir that follows then cannot delete the running image, so a live orphaned daemon and its ~224 MB tree would survive every uninstall. Ported into the live include: the ${APP_EXECUTABLE_FILENAME} kill, the USERNAME filter that keeps an elevated machine-wide uninstall out of another logged-on user's session, and the register save/restore around both. The legacy orca-terminal-daemon.exe kill stays so hosts left by older builds are still reaped. The ratchet that was meant to catch exactly this pinned only the legacy image name, which main's stale macro already satisfied, so it passed both ways. It now asserts the app-exe kill and the USERNAME filter, against comment-stripped script — the prose above the macro names both image names, so a toContain over the raw file proves nothing. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
119 lines
11 KiB
Markdown
119 lines
11 KiB
Markdown
# Windows daemon-host relocation
|
|
|
|
On Windows the terminal daemon does not run from the install directory. Before it forks the
|
|
daemon, Orca materializes a trimmed copy of its own runtime under
|
|
`%LOCALAPPDATA%\Orca\daemon-host\<app version>\` and forks the daemon from there
|
|
(`src/main/daemon/daemon-host-relocation.ts`). This is what keeps live terminals alive across an
|
|
auto-update and across a crash of the main process.
|
|
|
|
Read this before changing the copy plan, the host exe name, the LOCALAPPDATA layout, or
|
|
`config/nsis/orca-installer-hooks.nsh`.
|
|
|
|
## What the relocation actually escapes
|
|
|
|
The killer is **electron-builder's process sweep, matched on image path** — not file deletion.
|
|
Windows will not delete a running image, so `RMDir /r "$INSTDIR"` cannot end the daemon on its own.
|
|
|
|
In app-builder-lib's `allowOnlyOneInstallerInstance.nsh`, `FIND_PROCESS` / `KILL_PROCESS` have two
|
|
branches:
|
|
|
|
| Branch | Condition | Selector |
|
|
| -------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Primary | `powershell.exe` runs, `Get-CimInstance` resolves, and `Get-ExecutionPolicy -Scope Process` is not `Restricted` | `Win32_Process` where `$_.Path.StartsWith('$INSTDIR', 'CurrentCultureIgnoreCase')` — **path-scoped** |
|
|
| Fallback | otherwise | per-user: `taskkill /F /IM "<AppName>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%"`; per-machine: the same without the username filter — **image-name-scoped** |
|
|
|
|
The probe reads the **process** scope, not the effective policy, and Group Policy writes
|
|
`MachinePolicy`/`UserPolicy` — so a GPO-managed host whose effective policy is `Restricted` still
|
|
exits 0 and takes the primary branch. The fallback is reached only when `powershell.exe` is absent,
|
|
`Get-CimInstance` does not resolve, PowerShell is blocked outright (WDAC/AppLocker, Server Core), or
|
|
an inherited `PSExecutionPolicyPreference=Restricted` is in the environment.
|
|
|
|
So on essentially every machine the sweep is path-scoped, and a daemon whose image lives under
|
|
`%LOCALAPPDATA%` is out of range regardless of what the file is called. **Survival is a property of
|
|
the path.** The name only matters on the fallback branch.
|
|
|
|
## Why the exe is copied verbatim (and not renamed)
|
|
|
|
The host exe keeps the app exe's own file name (`daemonHostExeName()` returns
|
|
`basename(process.execPath)`), so the relocated image is a byte-for-byte copy of the app binary
|
|
under its original name.
|
|
|
|
An earlier revision copied it as `orca-terminal-daemon.exe` specifically so the fallback
|
|
`taskkill /IM Orca.exe` could not match. That bought survival on the rare no-PowerShell host and
|
|
cost a textbook defence-evasion signature: _a process copies its own image into a user-writable
|
|
directory under a different name so a kill-by-image-name cannot match it, then runs detached and
|
|
survives the installer._ Microsoft Defender for Endpoint flagged it as MITRE **T1036
|
|
(Masquerading)**, and — because it is the process every other flagged action is attributed to — it
|
|
acted as a reputation multiplier on unrelated findings. No VS Code fork does this.
|
|
|
|
Trading the fallback branch for the name is the right trade:
|
|
|
|
- On the primary branch nothing changes: the daemon still survives the update.
|
|
- On the fallback branch the daemon is killed with the app and terminals **cold-restore** on
|
|
relaunch. That is the documented pre-relocation behaviour, a first-class outcome the update
|
|
harness already asserts (`--expect cold-restore`), not a failure.
|
|
- Relocation is fail-open end to end anyway: any materialization failure returns `null` and the
|
|
caller forks the install-dir host.
|
|
|
|
One new failure mode comes with it, on the fallback branch only. The daemon now matches
|
|
`FIND_PROCESS` under the app's image name, so it enters electron-builder's retry loop
|
|
(`allowOnlyOneInstallerInstance.nsh:136-141`). If the `taskkill` there fails to end it — an elevated
|
|
or otherwise unkillable host — the loop reaches `MessageBox ... /SD IDCANCEL` and `Quit`s, aborting a
|
|
silent update rather than completing it. Under the old distinct name the daemon was invisible to
|
|
that loop. Low probability (fallback branch _and_ an unkillable daemon), but it is a real new path.
|
|
|
|
What this does **not** buy. Two things bound the win honestly:
|
|
|
|
- The strongest T1036 indicator is a PE-resource-vs-disk-name mismatch, and it was **never firing**:
|
|
the shipped binary's `OriginalFilename` is empty (only `InternalName = Orca` is set), so there was
|
|
no embedded name for the old disk name to contradict.
|
|
- The remaining behaviour — a signed app copying its own ~225 MB image into user-writable
|
|
`%LOCALAPPDATA%` and running it detached under `ELECTRON_RUN_AS_NODE=1` — is still execution from
|
|
a non-standard user-writable location, which maps to **T1036.005** and is a standard heuristic on
|
|
its own.
|
|
|
|
So this removes a real but partial signal. Expect the score to drop; do not expect the process to
|
|
stop being scored.
|
|
|
|
## Options that were rejected
|
|
|
|
| Option | Why not |
|
|
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Materialize the tree from the NSIS installer | The daemon host is ~246 MB. Writing it at install time doubles install footprint and lengthens the window in which the app is down during a silent update. Worse, on a per-machine install (`INSTALL_MODE_PER_ALL_USERS`) the installer runs as the installing admin, so `$LOCALAPPDATA` is the wrong user's — every other user still needs the runtime path, which means the runtime self-copy stays in the product and the signal is only made rarer. |
|
|
| Ship a second signed `orca-terminal-daemon.exe` in the installer | `Orca.exe` is 235,555,328 bytes (224.6 MiB). electron-builder's NSIS uses solid LZMA with a 64 MB dictionary, so a second copy 224 MB downstream does not dedupe; the compressed installer grows by roughly a whole compressed Electron binary, paid by every user on every update download. It also does not remove the runtime copy — the helper still has to reach `%LOCALAPPDATA%` to escape the sweep — so it buys the same signal reduction as the verbatim copy at a large download cost. |
|
|
| Override `customCheckAppRunning` to force a path-scoped kill on both branches | Cheap to write (~6 lines: `!include "getProcessInfo.nsh"`, `Var pid`, and a macro that pins `IsPowerShellAvailable`, reusing upstream's dialog, retry loop and elevated handling) — but wrong at any size. Forcing the PowerShell branch on a host where PowerShell is genuinely absent makes `FIND_PROCESS` and `KILL_PROCESS` silently no-op, so the installer proceeds with the **real app** still running and its files in use. That is a worse outcome than the cold restore it would prevent, so this is not worth doing ever, not merely not now. |
|
|
| Hardlink instead of copy | Avoids the 246 MB entirely and is not a "copy" at all, but is NTFS-and-same-volume-only and introduces fresh failure modes (link counts, AV interception, cross-volume installs). Worth revisiting deliberately, not as part of a signal fix. |
|
|
|
|
## Invariants to preserve
|
|
|
|
- The host exe name is **derived from `process.execPath`**, never a literal. A future
|
|
`executableName` or dev-channel rename must follow automatically; pinning a name of our own is
|
|
how the mismatch creeps back.
|
|
- The daemon is identified by **PID and command line**, never by image name — in the product
|
|
(`daemon-pid-file-parse`, `daemon-process-inspection`) and in the harness
|
|
(`tests/tools/win-update-e2e/daemon-processes.mjs`). Nothing may start matching on the exe name.
|
|
- `config/nsis/orca-installer-hooks.nsh` kills the daemon by image name. That now also matches the
|
|
app's own exe, which is correct on a genuine uninstall — the product is being removed — but its
|
|
`${isUpdated}` guard must stay: electron-builder runs the uninstaller during every update's
|
|
`uninstallOldVersion`, and killing the daemon there defeats the whole feature. The legacy
|
|
`orca-terminal-daemon.exe` name stays in the macro to reap hosts left by older builds.
|
|
- `LOCAL_HOST_ROOT_NAME` in `daemon-host-relocation.ts` and the path in the uninstall macro are the
|
|
same directory. Change both together.
|
|
|
|
## Verifying a change
|
|
|
|
Unit coverage lives in `src/main/daemon/daemon-host-relocation.test.ts` (copy plan, verbatim
|
|
naming, marker/atomic publish, fail-open, prune veto). Nothing in unit tests can prove survival, so
|
|
any change to this file or to the NSIS macro needs the packaged harnesses:
|
|
|
|
- `.github/workflows/win-update-survival-e2e.yml` — builds an installer from the branch and updates
|
|
it over itself with `--expect survival`. The primary proof.
|
|
- `.github/workflows/win-crash-survival-e2e.yml` — proves the daemon survives a main-process crash.
|
|
- `.github/workflows/windows-terminal-restart-e2e.yml` — terminal restart behaviour.
|
|
- `.github/workflows/win-update-e2e.yml` — release-tag-to-release-tag update, both `survival` and
|
|
`cold-restore` profiles.
|
|
|
|
All four are `workflow_dispatch`-only (the two update workflows also carry a push trigger pinned to
|
|
one historical feature branch), so they must be dispatched by hand against this branch before
|
|
merging a change here — which requires the workflow files to already exist on `main`.
|