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>
127 lines
6.4 KiB
JavaScript
127 lines
6.4 KiB
JavaScript
import { existsSync } from 'node:fs'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { basename } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
|
|
|
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
|
|
|
|
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
|
|
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
|
|
// match, or the guard below would be unfalsifiable.
|
|
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
|
|
|
|
// The hooks file documents the forbidden line in prose, so match executable script only.
|
|
const stripNsisCommentLines = (source) =>
|
|
source
|
|
.split('\n')
|
|
.filter((line) => !/^\s*[;#]/.test(line))
|
|
.join('\n')
|
|
|
|
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
|
|
|
|
describe('electron-builder markdown file associations', () => {
|
|
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
|
|
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
|
|
// — silently taking .md from whichever editor owns it, for every existing user on their
|
|
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
|
|
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
|
|
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
|
|
it('never claims the Windows default markdown handler', () => {
|
|
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
|
|
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
|
|
})
|
|
|
|
it('joins the macOS Open With list for every markdown extension without owning it', () => {
|
|
const associations = electronBuilderConfig.mac.fileAssociations
|
|
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
|
|
expect([...associations].map((association) => association.ext).sort()).toEqual(
|
|
[...MARKDOWN_EXTENSIONS].sort()
|
|
)
|
|
for (const association of associations) {
|
|
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
|
|
}
|
|
})
|
|
|
|
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
|
|
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
|
|
// the default. A fileAssociations entry would ship a redundant glob override instead.
|
|
it('reuses the existing shared-mime-info markdown type on Linux', () => {
|
|
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
|
|
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
|
|
})
|
|
|
|
it('points the single NSIS include at the installer hooks file on disk', () => {
|
|
const includePath = electronBuilderConfig.nsis.include
|
|
expect(existsSync(includePath)).toBe(true)
|
|
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
|
|
})
|
|
|
|
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
|
|
// the assertion below is a live check rather than a regex that can never fire.
|
|
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
|
|
for (const takeover of [
|
|
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
|
|
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
|
|
]) {
|
|
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
|
|
}
|
|
expect(
|
|
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
|
|
).not.toMatch(DEFAULT_HANDLER_WRITE)
|
|
// Comment stripping must drop prose that quotes the bad line without swallowing a real
|
|
// one that happens to carry a trailing comment.
|
|
const stripped = stripNsisCommentLines(
|
|
[
|
|
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
|
|
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
|
|
].join('\n')
|
|
)
|
|
expect(stripped.split('\n')).toHaveLength(1)
|
|
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
|
|
})
|
|
|
|
it('registers Windows markdown Open With additively, never as the default', async () => {
|
|
const hooks = await readInstallerHooks()
|
|
|
|
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
|
|
// The additive hint that puts Orca in Explorer's "Open with" list.
|
|
expect(hooks).toMatch(
|
|
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
|
|
)
|
|
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
|
|
for (const ext of MARKDOWN_EXTENSIONS) {
|
|
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
|
|
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
|
|
}
|
|
expect(hooks).toMatch(/!macro\s+customInstall\b/)
|
|
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
|
|
})
|
|
|
|
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
|
|
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
|
|
// sweep would silently orphan a running daemon host on every uninstall.
|
|
//
|
|
// Asserted against comment-stripped script, and on the app exe name first: the relocated
|
|
// host is a verbatim copy of the app exe (daemonHostExeName, daemon-host-relocation.ts),
|
|
// so a macro that kills only orca-terminal-daemon.exe matches no running process. The
|
|
// prose above the macro names both, so a toContain over the raw file proves nothing.
|
|
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
|
|
const script = stripNsisCommentLines(await readInstallerHooks())
|
|
|
|
expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?\$\{APP_EXECUTABLE_FILENAME\}"?/)
|
|
// Legacy name, so hosts left by builds that renamed the copy still get reaped.
|
|
expect(script).toMatch(/taskkill[^\n]*\/IM\s+"?orca-terminal-daemon\.exe"?/)
|
|
// Scopes both kills to the uninstalling user: an elevated machine-wide uninstall must
|
|
// not reach another logged-on user's session.
|
|
expect(script).toMatch(/\/FI\s+"USERNAME eq /)
|
|
expect(script).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
|
|
// Without this guard, uninstallOldVersion would kill the daemon on every update —
|
|
// defeating the relocation that keeps terminals alive across updates.
|
|
expect(script).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
|
|
})
|
|
})
|