mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 08:02:35 +00:00
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
import { execFileSync } from 'child_process'
|
|
import { randomBytes } from 'crypto'
|
|
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs'
|
|
import { dirname, win32 as pathWin32 } from 'path'
|
|
|
|
let cachedWindowsUserSid: string | null | undefined
|
|
|
|
export function writeSecureJsonFile(targetPath: string, value: unknown): void {
|
|
writeSecureFile(targetPath, JSON.stringify(value, null, 2))
|
|
}
|
|
|
|
export function writeSecureFile(targetPath: string, contents: string): void {
|
|
const dir = dirname(targetPath)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
}
|
|
hardenSecurePath(dir, { isDirectory: true, platform: process.platform })
|
|
|
|
const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
|
|
try {
|
|
writeFileSync(tmpFile, contents, {
|
|
encoding: 'utf-8',
|
|
mode: 0o600
|
|
})
|
|
hardenSecurePath(tmpFile, { isDirectory: false, platform: process.platform })
|
|
renameSync(tmpFile, targetPath)
|
|
// Why: these files carry runtime auth/device credentials; the published
|
|
// path must remain current-user only after the atomic rename.
|
|
hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform })
|
|
} catch (error) {
|
|
rmSync(tmpFile, { force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export function hardenExistingSecureFile(targetPath: string): void {
|
|
const dir = dirname(targetPath)
|
|
if (existsSync(dir)) {
|
|
hardenSecurePath(dir, { isDirectory: true, platform: process.platform })
|
|
}
|
|
if (existsSync(targetPath)) {
|
|
hardenSecurePath(targetPath, { isDirectory: false, platform: process.platform })
|
|
}
|
|
}
|
|
|
|
export function hardenSecurePath(
|
|
targetPath: string,
|
|
options: {
|
|
isDirectory: boolean
|
|
platform: NodeJS.Platform
|
|
}
|
|
): void {
|
|
if (options.platform === 'win32') {
|
|
bestEffortRestrictWindowsPath(targetPath, options.isDirectory)
|
|
return
|
|
}
|
|
chmodSync(targetPath, options.isDirectory ? 0o700 : 0o600)
|
|
}
|
|
|
|
const WINDOWS_RESTRICT_ACL_SCRIPT = `
|
|
$ErrorActionPreference = 'Stop'
|
|
$path = $args[0]
|
|
$currentUserSid = $args[1]
|
|
$isDirectory = $args[2] -eq '1'
|
|
$allowedSidTexts = @($currentUserSid, 'S-1-5-18', 'S-1-5-32-544')
|
|
$allowedSids = @{}
|
|
foreach ($sidText in $allowedSidTexts) {
|
|
$allowedSids[$sidText] = $true
|
|
}
|
|
$acl = Get-Acl -LiteralPath $path
|
|
$acl.SetAccessRuleProtection($true, $false)
|
|
foreach ($rule in @($acl.Access)) {
|
|
[void]$acl.RemoveAccessRuleSpecific($rule)
|
|
}
|
|
$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None
|
|
if ($isDirectory) {
|
|
$inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
|
|
}
|
|
foreach ($sidText in $allowedSidTexts) {
|
|
$sid = [System.Security.Principal.SecurityIdentifier]::new($sidText)
|
|
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
$sid,
|
|
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
$inheritanceFlags,
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
)
|
|
[void]$acl.AddAccessRule($rule)
|
|
}
|
|
Set-Acl -LiteralPath $path -AclObject $acl
|
|
$verifiedAcl = Get-Acl -LiteralPath $path
|
|
if (-not $verifiedAcl.AreAccessRulesProtected) {
|
|
throw 'ACL inheritance is still enabled'
|
|
}
|
|
$fullControl = [System.Security.AccessControl.FileSystemRights]::FullControl
|
|
foreach ($rule in @($verifiedAcl.Access)) {
|
|
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
|
if (-not $allowedSids.ContainsKey($sid)) {
|
|
throw "Unexpected ACL entry $sid"
|
|
}
|
|
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
throw "Unexpected ACL deny entry $sid"
|
|
}
|
|
if (($rule.FileSystemRights -band $fullControl) -ne $fullControl) {
|
|
throw "ACL entry $sid does not grant FullControl"
|
|
}
|
|
}
|
|
`.trim()
|
|
|
|
function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void {
|
|
const currentUserSid = getCurrentWindowsUserSid()
|
|
if (!currentUserSid) {
|
|
return
|
|
}
|
|
try {
|
|
execFileSync(
|
|
getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'),
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
WINDOWS_RESTRICT_ACL_SCRIPT,
|
|
targetPath,
|
|
currentUserSid,
|
|
isDirectory ? '1' : '0'
|
|
],
|
|
{
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
timeout: 5000
|
|
}
|
|
)
|
|
} catch {
|
|
// Why: credential-file hardening should not prevent Orca from starting on
|
|
// Windows machines where PowerShell ACL APIs are unavailable or locked down.
|
|
}
|
|
}
|
|
|
|
function getCurrentWindowsUserSid(): string | null {
|
|
if (cachedWindowsUserSid !== undefined) {
|
|
return cachedWindowsUserSid
|
|
}
|
|
try {
|
|
const output = execFileSync(
|
|
getWindowsSystemToolPath('whoami.exe'),
|
|
['/user', '/fo', 'csv', '/nh'],
|
|
{
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
timeout: 5000
|
|
}
|
|
).trim()
|
|
const columns = parseCsvLine(output)
|
|
cachedWindowsUserSid = columns[1] ?? null
|
|
} catch {
|
|
cachedWindowsUserSid = null
|
|
}
|
|
return cachedWindowsUserSid
|
|
}
|
|
|
|
function getWindowsSystemToolPath(relativeSystem32Path: string): string {
|
|
const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows'
|
|
return pathWin32.join(systemRoot, 'System32', relativeSystem32Path)
|
|
}
|
|
|
|
function parseCsvLine(line: string): string[] {
|
|
return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, ''))
|
|
}
|
|
|
|
export function __resetSecureFileWindowsUserSidForTests(): void {
|
|
cachedWindowsUserSid = undefined
|
|
}
|