mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(windows): restrict secure files in-process instead of spawning icacls Every secure write on Windows spawned icacls.exe five times on the calling thread (the Electron main thread for most stores): verify, /reset, /inheritance:r /grant:r, verify, verify. Process creation under endpoint security costs ~0.6 s each, so each write froze the UI for seconds (#26027). The /reset + /inheritance:r pair is also what behavioural EDR scores as ransomware tradecraft (#25583). Add native/windows-security (@orca/windows-security): a small N-API addon over advapi32 that reads and writes DACLs in-process. It exposes the token user SID, a DACL read as SDDL, a DACL write (sync and on the thread pool), a create-with-DACL file write, and an additive grant. Policy and verification stay in TypeScript. - writeSecureFile creates each temp file already carrying the protected user/SYSTEM/Administrators DACL (CreateFileW + SECURITY_ATTRIBUTES), reads it back once, and renames it. No ACL is modified on the write path and no process starts. Before: 5-7 spawns, 94-138 ms per write. After: 0 spawns, ~0.5 ms. - A secure directory is verified once per process. It is rewritten only when verification fails, on the thread pool, because the write walks every descendant (~2 s on a 29k-entry userData). - The verifier follows OpenSSH's rule: only the user, SYSTEM and Administrators may be granted anything, and the path must be protected (or, for a file, inherit from a secure parent). DACLs written by the icacls-era hardening pass unchanged, so there is no migration. - If the addon cannot load, the write still happens and reports unrestricted, logged once. There is no icacls or PowerShell fallback. - grantDirAcl, the startup userData grant, and the install-dir probe and package repair use the same addon, so whoami.exe and icacls.exe are no longer spawned anywhere at runtime. - orcad's Windows slots ship the addon beside their bundles (built per arch, PE-machine checked). Its data-root privacy check fails closed, so without the addon a Windows orcad would not start. Fixes #26027 Fixes #25583 * refactor(secure-file): harden existing Windows files through the sync lane The read path called restrictWindowsPathSync directly and recorded success itself; the existing sync lane already does both. One call site fewer. * refactor(windows-security): one load, one rule, no fallthrough (review #28135) - The descriptor parser is anchored, so a conditional ACE (its body holds parentheses) or anything else it cannot read fails closed instead of verifying the ACEs before it. - findSecureDaclViolation is one flat rule: protected, allow rules only for the user, SYSTEM and Administrators (OpenSSH's ACE rule), and full control for the user. The inherits-from-a-secure-parent exception is gone: it accepted exactly what loosening the parent reaches. - The addon and the user SID load once, behind one wrapper that adds the extended-length prefix to every path and runs async DACL writes to one path in order, so a startup grant cannot land after a re-lock and restore the broader DACL. - With the addon loaded, a failed born-restricted create throws with the matching Node error code instead of falling back to a plain write. - A grant leaves a NULL DACL untouched instead of turning 'everyone' into 'only this SID'. WriteFile runs once and fails on a short write. - The artifact create-intent store writes through writeSecureFile and drops its own temp writer, directory LRU and synchronous directory re-lock. - Test seams the global fake makes redundant are gone. * fix(packaging): fail a Windows build whose workspace addon binary is missing or foreign-arch The @orca/windows-* workspace links exist on every host, so their presence proved nothing: a missing or wrong-architecture .node only surfaced at runtime, as unrestricted secure files or an orcad that refuses to start. beforePack now reads each addon's PE machine against the target arch. * refactor(windows-security): prune what the native ACL left dead (review #28135) - Sync-spawn ratchet: hardenSecurePath and the sync-flag exemption it alone used are gone, and the advice and baseline header no longer say secure writers run icacls. - The install-dir probe reads ACEs through parseSddlDacl, so an ACE it cannot parse fails the whole reading closed instead of being skipped; AC is a resolved alias there now. - The packaging check reads the addon binary through node_modules, the copy that ships. - No winCode-to-errno table: nothing reads .code off a failed secure write. - The load memo is not mutated; secure-path keeps its own log-once flag. - grantInstallDirPackageAcl is a plain function, and secure-file restricts a Windows directory and a POSIX path directly instead of through one two-platform helper. * fix(windows-security): resolve LA in real-ACL tests; tear a file whose close fails - The GitHub Windows runner is the built-in Administrator (RID 500), whose SID reads back as LA. Production already resolves it through localDomainSidOf; the win32 test helpers parsed without it. One shared readDaclOf resolves aliases for every real-ACL suite, and the verifier's unit test covers an LA-aliased directory and LA as a stranger. - createFileWithDacl treats a failed CloseHandle like a failed write (lost data on a network volume) and deletes the torn file. - Drop a duplicate test reset and a typo. * fix(windows-security): load only from inside the app; read DACLs as structure (review #28135) - Loader: orcad's staged binary first, then the workspace package only if its real path is inside the app (process.resourcesPath, or this checkout). A bare specifier otherwise walks every parent up to C:\node_modules, which any local user may create, and orcad holds credentials. - The addon reads a DACL as { protected, aces: [{ type, flags, mask, sid }] } via GetAce, with a SID only for plain allow/deny ACEs and null for a NULL DACL. findSecureDaclViolation and the install-dir probe check that structure, so conditional, callback and object ACEs fail closed. The SDDL parser, its alias table, LA/localDomainSid resolution and getDaclSddl are gone; SDDL is only the write format. - Install-dir tree pass: files flagless as before, but a directory only when its own DACL is protected (inheritably). Re-granting inheriting directories re-propagated each subtree (3.2 s for 1,093 directories). The root is no longer granted, or counted, twice. The two passes are plain functions behind a { root, tree } seam. - One namespacing (path.win32.toNamespacedPath) at the addon boundary, also the per-path ordering key; the comment states the real sync-grant invariant. - restrictWindowsDirectory(dir): Promise<boolean> replaces the callback lane; the reporter is guarded once, so the 'settle' stage is gone. - Sync-spawn ratchet: the secure writers no longer spawn or block, so they leave the gate (109 -> 61 uses). - Errors name the Win32 call and code only; a failed GetSecurityDescriptorControl fails the write instead of defaulting to unprotected. * test(secure-file): assert the durable POSIX write's outcome, not the old chmod order writeSecureFile now chmods the temp file to 0o600 before fsyncing it, so the fsync also covers the mode change and the file is owner-writable when opened. Assert what matters under umask 0o200: the write succeeds, the temp file is fsynced read-only, and the published file is exactly 0o600. * fix(windows-security): namespace paths with the host's toNamespacedPath path.win32.toNamespacedPath rewrote POSIX temp paths (/tmp/x -> \tmp\x) in the Linux runs of the Windows-logic unit tests, so the fake addon wrote to the wrong place. The addon only runs on Windows, where the host function is the win32 one; elsewhere it is the identity. The long-path assertion runs on Windows only. * test(windows-security): assert the install-dir long-path prefix on Windows only The prefix comes from the host's toNamespacedPath, which is the identity off Windows; the Linux run of this suite expected the win32 escape. * fix(windows-user-data-acl): grant the root even when its children cannot be listed (review #28135) A readdir failure on userData aborted the whole grant, so the root grant that could repair a DACL refusing the listing was never attempted. The listing failure is now recorded, the root is still granted, and the marker stays withheld so the next launch retries. * test(windows-security): wait for relock propagation, accept Entra ID SIDs (review #28135) - The large-relock test waited only for the parent to turn protected, which happens before propagation reaches the children, then read a child. It now waits on the parent and both ends of the child range. - The token-user test rejected S-1-12-1 (Entra ID) accounts, failing the suite on Entra-joined dev machines. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { randomBytes } from 'node:crypto'
|
|
import {
|
|
chmodSync,
|
|
closeSync,
|
|
existsSync,
|
|
fsyncSync,
|
|
mkdirSync,
|
|
openSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { dirname } from 'node:path'
|
|
import {
|
|
DEFAULT_HARDENING_CACHE_BOUNDS,
|
|
SecurePathHardeningCache,
|
|
type SecurePathHardeningCacheBounds
|
|
} from './secure-path-hardening-cache'
|
|
import {
|
|
configureHardeningRetryBudget,
|
|
mayAttemptHardening,
|
|
recordHardeningOutcome
|
|
} from './secure-path-hardening-retry-budget'
|
|
import {
|
|
restrictWindowsDirectory,
|
|
restrictWindowsPathSync,
|
|
writeRestrictedWindowsFile
|
|
} from './secure-path-windows-acl'
|
|
import {
|
|
getHardenedPathCacheEntry,
|
|
hardenedPathCacheEntriesMatch,
|
|
type HardenedPathCacheEntry
|
|
} from './secure-path-metadata-snapshot'
|
|
|
|
const UNSUPPORTED_DIRECTORY_FSYNC_CODES = new Set(['EINVAL', 'ENOTSUP', 'EOPNOTSUPP'])
|
|
|
|
// Why: read paths poll (~2/s, #4901); a metadata-keyed cache keeps an unchanged file from being re-verified and re-logged.
|
|
let hardenedPathsThisProcess = new SecurePathHardeningCache<HardenedPathCacheEntry>(
|
|
DEFAULT_HARDENING_CACHE_BOUNDS
|
|
)
|
|
|
|
// Why: child writes constantly bump a dir's mtime, so cache dirs by path (not metadata); a Windows rewrite walks the whole tree.
|
|
// Limitation: a dir deleted+recreated in-process won't re-harden; fine since we never delete our secure dirs at runtime.
|
|
let hardenedDirectoryPathsThisProcess = new SecurePathHardeningCache<true>(
|
|
DEFAULT_HARDENING_CACHE_BOUNDS
|
|
)
|
|
|
|
function hardenWindowsDirectoryOnce(dirPath: string): void {
|
|
// Why the floor: read paths poll at ~2/s (#4901); retrying every failure there would storm.
|
|
if (hardenedDirectoryPathsThisProcess.get(dirPath) || !mayAttemptHardening(dirPath)) {
|
|
return
|
|
}
|
|
// Cache before the ACL lands so concurrent writes don't restorm; a failure drops it, under the retry budget.
|
|
hardenedDirectoryPathsThisProcess.set(dirPath, true)
|
|
// Why async: a directory rewrite propagates to every descendant, ~2 s on a 29k-entry userData.
|
|
void restrictWindowsDirectory(dirPath).then((restricted) => {
|
|
recordHardeningOutcome(dirPath, restricted)
|
|
if (!restricted) {
|
|
hardenedDirectoryPathsThisProcess.delete(dirPath)
|
|
}
|
|
})
|
|
}
|
|
|
|
function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean {
|
|
if (isDirectory && process.platform === 'win32') {
|
|
hardenWindowsDirectoryOnce(targetPath)
|
|
return true
|
|
}
|
|
|
|
const currentEntry = getHardenedPathCacheEntry(targetPath, isDirectory)
|
|
if (!currentEntry) {
|
|
hardenedPathsThisProcess.delete(targetPath)
|
|
}
|
|
const cachedEntry = hardenedPathsThisProcess.get(targetPath)
|
|
if (currentEntry && cachedEntry && hardenedPathCacheEntriesMatch(currentEntry, cachedEntry)) {
|
|
return true
|
|
}
|
|
if (process.platform === 'win32') {
|
|
return hardenExistingWindowsFile(targetPath)
|
|
}
|
|
chmodSync(targetPath, isDirectory ? 0o700 : 0o600)
|
|
rememberHardenedPath(targetPath, isDirectory)
|
|
return true
|
|
}
|
|
|
|
/** A file has no descendants, so verifying and fixing it in-process costs microseconds. */
|
|
function hardenExistingWindowsFile(targetPath: string): boolean {
|
|
// Why the floor: this is the read path, polled at ~2/s; a host that can never restrict
|
|
// (FAT32, some shares) would otherwise log on every poll.
|
|
if (!mayAttemptHardening(targetPath)) {
|
|
return false
|
|
}
|
|
const restricted = restrictWindowsPathSync(targetPath, false)
|
|
recordHardeningOutcome(targetPath, restricted)
|
|
if (restricted) {
|
|
rememberHardenedPath(targetPath, false)
|
|
}
|
|
return restricted
|
|
}
|
|
|
|
/** Returns false when the file was written but its permissions could not be restricted. */
|
|
export function writeSecureJsonFile(targetPath: string, value: unknown): boolean {
|
|
return writeSecureFile(targetPath, JSON.stringify(value, null, 2))
|
|
}
|
|
|
|
/** Returns false when the file was written but its permissions could not be restricted. */
|
|
export function writeDurableSecureJsonFile(targetPath: string, value: unknown): boolean {
|
|
return writeSecureFile(targetPath, JSON.stringify(value, null, 2), { durable: true })
|
|
}
|
|
|
|
/**
|
|
* Writes `contents` and restricts the result to the current user.
|
|
*
|
|
* Returns whether the restriction actually took, so a caller storing a credential can react. On
|
|
* Windows with the ACL addon loaded the file is created already restricted, and a failed create
|
|
* throws like any write failure (fail closed). Only when the addon cannot load is the file written
|
|
* plainly, best-effort, and reported as unrestricted. A restriction the filesystem drops (FAT32,
|
|
* some shares) also reports false.
|
|
*
|
|
* An optional publication predicate runs after staged hardening; false discards the staged file.
|
|
*
|
|
* The return value covers the *file* only. The parent directory is hardened fire-and-forget — on
|
|
* Windows on the thread pool, settling after this returns — so a `true` here says nothing about
|
|
* the directory's ACL.
|
|
*/
|
|
export function writeSecureFile(
|
|
targetPath: string,
|
|
contents: string,
|
|
options: { durable?: boolean; shouldPublish?: () => boolean } = {}
|
|
): boolean {
|
|
const dir = dirname(targetPath)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
}
|
|
// Windows dir hardening stays async + path-cached (it stormed the main thread, #4901); POSIX keeps the metadata cache to catch chmod/ctime drift.
|
|
hardenSecurePathOnce(dir, true)
|
|
|
|
const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
|
|
try {
|
|
// Why born restricted on Windows: writeFileSync's mode is a no-op there, and a file created
|
|
// plainly would hold the credential under the directory's inherited DACL until a later fix.
|
|
const restricted =
|
|
process.platform === 'win32'
|
|
? writeRestrictedWindowsFile(tmpFile, contents)
|
|
: writeModeRestrictedFile(tmpFile, contents)
|
|
if (options.durable) {
|
|
fsyncFileSync(tmpFile)
|
|
}
|
|
// Recheck after hardening; the check and rename are separate, non-atomic operations.
|
|
if (options.shouldPublish && !options.shouldPublish()) {
|
|
rmSync(tmpFile, { force: true })
|
|
return false
|
|
}
|
|
// The rename publishes the staged inode, so its DACL or mode is already the published file's.
|
|
renameSync(tmpFile, targetPath)
|
|
// Cache only on confirmed success so failures retry; a success also clears the read path's backoff.
|
|
if (restricted) {
|
|
rememberHardenedPath(targetPath, false)
|
|
recordHardeningOutcome(targetPath, true)
|
|
}
|
|
if (options.durable) {
|
|
bestEffortFsyncDirectorySync(dir)
|
|
}
|
|
return restricted
|
|
} catch (error) {
|
|
rmSync(tmpFile, { force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function writeModeRestrictedFile(tmpFile: string, contents: string): boolean {
|
|
writeFileSync(tmpFile, contents, { encoding: 'utf-8', mode: 0o600 })
|
|
// The umask can strip bits from `mode` but never add them; chmod sets exactly 0o600.
|
|
chmodSync(tmpFile, 0o600)
|
|
return true
|
|
}
|
|
|
|
function fsyncPathSync(path: string, flags: 'r' | 'r+'): void {
|
|
const descriptor = openSync(path, flags)
|
|
try {
|
|
fsyncSync(descriptor)
|
|
} finally {
|
|
closeSync(descriptor)
|
|
}
|
|
}
|
|
|
|
export function fsyncFileSync(path: string): void {
|
|
// FlushFileBuffers requires a write-capable handle on Windows.
|
|
fsyncPathSync(path, process.platform === 'win32' ? 'r+' : 'r')
|
|
}
|
|
|
|
export function bestEffortFsyncDirectorySync(directory: string): void {
|
|
if (process.platform === 'win32') {
|
|
return
|
|
}
|
|
try {
|
|
fsyncPathSync(directory, 'r')
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
UNSUPPORTED_DIRECTORY_FSYNC_CODES.has((error as NodeJS.ErrnoException).code ?? '')
|
|
) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Errors that mean the contents were never seen, so they say nothing about what the file holds.
|
|
*
|
|
* `ENOENT` is deliberately absent: "there is no file" genuinely licenses creating one. So is a
|
|
* parse failure, which means the bytes WERE read and were garbage - the self-heal these stores
|
|
* were built for. The distinction is "could not read it" versus "read it and it was garbage".
|
|
*
|
|
* Why it matters: a reader that treats every failure as corruption regenerates the file, and the
|
|
* regeneration succeeds - `renameSync` over an unreadable file needs `FILE_DELETE_CHILD` on the
|
|
* parent, not `DELETE` on the file - so the original is destroyed by the code meant to heal it.
|
|
* `EPERM`/`EACCES` is the hardened-DACL case: a file granting a SID this process does not hold,
|
|
* reachable through a relocated user-data path, a share or roaming profile, a restored backup
|
|
* under a new local SID, or a half-applied harden. The rest are transient and, on Windows, more
|
|
* likely than that: `EBUSY` is what antivirus produces by holding a file open at the moment of a
|
|
* read, which for a credential read on the startup path is an ordinary Tuesday.
|
|
*/
|
|
export function isUnreadableError(error: unknown): boolean {
|
|
const code = (error as NodeJS.ErrnoException | null)?.code
|
|
return (
|
|
code === 'EPERM' ||
|
|
code === 'EACCES' ||
|
|
code === 'EBUSY' ||
|
|
code === 'EMFILE' ||
|
|
code === 'ENFILE' ||
|
|
code === 'EIO'
|
|
)
|
|
}
|
|
|
|
export function hardenExistingSecureFile(targetPath: string): void {
|
|
const dir = dirname(targetPath)
|
|
if (existsSync(dir)) {
|
|
hardenSecurePathOnce(dir, true)
|
|
}
|
|
if (existsSync(targetPath)) {
|
|
hardenSecurePathOnce(targetPath, false)
|
|
}
|
|
}
|
|
|
|
/** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */
|
|
function rememberHardenedPath(targetPath: string, isDirectory: boolean): void {
|
|
const entry = getHardenedPathCacheEntry(targetPath, isDirectory)
|
|
if (entry) {
|
|
hardenedPathsThisProcess.set(targetPath, entry)
|
|
} else {
|
|
hardenedPathsThisProcess.delete(targetPath)
|
|
}
|
|
}
|
|
|
|
export function __resetSecureFileHardenedPathsForTests(
|
|
bounds: SecurePathHardeningCacheBounds = DEFAULT_HARDENING_CACHE_BOUNDS
|
|
): void {
|
|
hardenedPathsThisProcess = new SecurePathHardeningCache(bounds)
|
|
hardenedDirectoryPathsThisProcess = new SecurePathHardeningCache(bounds)
|
|
configureHardeningRetryBudget(bounds)
|
|
}
|
|
|
|
export function __getSecureFileHardeningCacheStateForTests(): {
|
|
paths: ReturnType<SecurePathHardeningCache<HardenedPathCacheEntry>['state']>
|
|
directories: ReturnType<SecurePathHardeningCache<true>['state']>
|
|
} {
|
|
return {
|
|
paths: hardenedPathsThisProcess.state(),
|
|
directories: hardenedDirectoryPathsThisProcess.state()
|
|
}
|
|
}
|