mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 07:32:14 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
import { lstatSync, unlinkSync } from 'node:fs';
|
||||
import { toNamespacedPath } from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
type Identity = { dev: bigint; ino: bigint; mode: bigint };
|
||||
type Handle = number | bigint;
|
||||
type Stage = 'admission' | 'identity' | 'disposition' | 'close' | 'absence';
|
||||
|
||||
export class FixtureDeleteError extends Error {
|
||||
readonly code: string;
|
||||
readonly syscall: string;
|
||||
constructor(public stage: Stage, public path: string, public win32Error?: number, public deadlineExceeded = false) {
|
||||
super(`Owned fixture deletion failed at ${stage}${deadlineExceeded ? ' (deadline)' : win32Error === undefined ? '' : ` (Windows ${win32Error})`}`);
|
||||
this.name = 'FixtureDeleteError';
|
||||
this.code = deadlineExceeded ? 'ETIMEDOUT' : win32Error === 32 ? 'EBUSY' : win32Error === 2 || win32Error === 3 ? 'ENOENT' : win32Error === 5 ? 'EACCES' : 'EIO';
|
||||
this.syscall = { admission: 'open', identity: 'fstat', disposition: 'unlink', close: 'close', absence: 'lstat' }[stage];
|
||||
}
|
||||
}
|
||||
|
||||
export interface FixtureDeleteBackend {
|
||||
open(file: string): Handle;
|
||||
identity(handle: Handle, file: string): { dev: bigint; ino: bigint; attributes: number; filesystem: string };
|
||||
dispose(handle: Handle, file: string): void;
|
||||
close(handle: Handle, file: string): void;
|
||||
absent(file: string): boolean;
|
||||
}
|
||||
|
||||
type LeaseClock = { now(): number; wait(ms: number): void; onSharing?(): void };
|
||||
const leaseClock: LeaseClock = { now: () => performance.now(), wait: ms => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } };
|
||||
|
||||
export function deleteWithFixtureLease(file: string, expected: Identity, deadline: number, verify: () => void,
|
||||
backend: FixtureDeleteBackend, clock: LeaseClock = leaseClock): { admissionProbes: number; waitedMs: number } {
|
||||
if (!Number.isFinite(deadline)) throw new Error('Invalid fixture deletion deadline');
|
||||
let handle: Handle | undefined;
|
||||
let firstSharing: FixtureDeleteError | undefined;
|
||||
let admissionProbes = 0;
|
||||
let waitedMs = 0;
|
||||
while (handle === undefined) {
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
try { admissionProbes++; handle = backend.open(file); }
|
||||
catch (error) {
|
||||
if (!(error instanceof FixtureDeleteError) || error.stage !== 'admission' || error.win32Error !== 32) throw error;
|
||||
if (!firstSharing) { firstSharing = error; clock.onSharing?.(); }
|
||||
const remaining = deadline - clock.now();
|
||||
if (remaining <= 0) throw firstSharing;
|
||||
const before = clock.now();
|
||||
clock.wait(Math.min(20, remaining));
|
||||
waitedMs += Math.max(0, clock.now() - before);
|
||||
}
|
||||
}
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
const current = backend.identity(handle, file);
|
||||
if (current.filesystem !== 'NTFS' || current.dev !== expected.dev || current.ino !== expected.ino
|
||||
|| (current.attributes & (0x1 | 0x10 | 0x400)) !== 0) throw new FixtureDeleteError('identity', file);
|
||||
verify();
|
||||
if (clock.now() >= deadline) throw firstSharing ?? new FixtureDeleteError('admission', file, undefined, true);
|
||||
backend.dispose(handle, file);
|
||||
} catch (error) { failed = true; failure = error; }
|
||||
try { backend.close(handle, file); }
|
||||
catch (error) {
|
||||
if (failed) console.error(JSON.stringify({ nativeFixtureDeleteCloseFailure: {
|
||||
stage: error instanceof FixtureDeleteError ? error.stage : 'close',
|
||||
win32Error: error instanceof FixtureDeleteError ? error.win32Error : undefined,
|
||||
} }));
|
||||
else { failed = true; failure = error; }
|
||||
}
|
||||
if (failed) throw failure;
|
||||
if (!backend.absent(file)) throw new FixtureDeleteError('absence', file);
|
||||
return { admissionProbes, waitedMs };
|
||||
}
|
||||
|
||||
export function createFixtureDeleteLease(deadline: number, onSharing?: () => void) {
|
||||
if (process.platform !== 'win32') return { unlink: (file: string, _identity: Identity, _verify: () => void) => unlinkSync(file), close: () => {} };
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
CreateFileW: { args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.u64], returns: FFIType.u64 },
|
||||
GetFileInformationByHandle: { args: [FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
GetVolumeInformationByHandleW: { args: [FFIType.u64, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
SetFileInformationByHandle: { args: [FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const backend: FixtureDeleteBackend = {
|
||||
open(file) {
|
||||
const name = Buffer.from(toNamespacedPath(file) + '\0', 'utf16le');
|
||||
const handle = kernel.symbols.CreateFileW(ptr(name), 0x10080, 3, null, 3, 0x00200000, 0);
|
||||
const error = kernel.symbols.GetLastError();
|
||||
if (BigInt(handle) === 0xffffffffffffffffn || BigInt(handle) === 0n) throw new FixtureDeleteError('admission', file, error);
|
||||
return handle;
|
||||
},
|
||||
identity(handle, file) {
|
||||
const info = Buffer.alloc(52);
|
||||
if (!kernel.symbols.GetFileInformationByHandle(handle, ptr(info))) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
const filesystem = Buffer.alloc(128);
|
||||
if (!kernel.symbols.GetVolumeInformationByHandleW(handle, null, 0, null, null, null, ptr(filesystem), 64)) throw new FixtureDeleteError('identity', file, kernel.symbols.GetLastError());
|
||||
return { dev: BigInt(info.readUInt32LE(28)), ino: (BigInt(info.readUInt32LE(44)) << 32n) | BigInt(info.readUInt32LE(48)),
|
||||
attributes: info.readUInt32LE(0), filesystem: filesystem.toString('utf16le').split('\0')[0] };
|
||||
},
|
||||
dispose(handle, file) {
|
||||
const flags = Buffer.alloc(4);
|
||||
flags.writeUInt32LE(3);
|
||||
if (!kernel.symbols.SetFileInformationByHandle(handle, 21, ptr(flags), 4)) throw new FixtureDeleteError('disposition', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
close(handle, file) {
|
||||
if (!kernel.symbols.CloseHandle(handle)) throw new FixtureDeleteError('close', file, kernel.symbols.GetLastError());
|
||||
},
|
||||
absent(file) {
|
||||
try { lstatSync(file); return false; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; }
|
||||
},
|
||||
};
|
||||
return {
|
||||
unlink(file: string, identity: Identity, verify: () => void) {
|
||||
if ((identity.mode & 0o170000n) !== 0o100000n) { verify(); unlinkSync(file); return; }
|
||||
const receipt = deleteWithFixtureLease(file, identity, deadline, verify, backend, { ...leaseClock, onSharing });
|
||||
if (receipt.admissionProbes > 1) console.log(JSON.stringify({ nativeFixtureSharingAdmission: {
|
||||
objectDev: identity.dev.toString(), objectIno: identity.ino.toString(), ...receipt, dispositionCalls: 1,
|
||||
} }));
|
||||
},
|
||||
close() { kernel.close(); },
|
||||
};
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { lstatSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
||||
|
||||
let stage = 'platform';
|
||||
|
||||
class FileOwnerProbeError extends Error {
|
||||
errorCode?: string;
|
||||
constructor(public stage: string, error: unknown) {
|
||||
super('owner_query_failed');
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTDIR', 'ENAMETOOLONG', 'ETIMEDOUT'].includes(code || '')) this.errorCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
function inspect() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
stage = 'input_decode';
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (typeof input.root !== 'string' || typeof input.file !== 'string' || !Number.isSafeInteger(input.testPid)) return { available: false, reason: 'invalid_input' };
|
||||
stage = 'resolve_root';
|
||||
const root = realpathSync(input.root);
|
||||
stage = 'resolve_file';
|
||||
const file = realpathSync(input.file);
|
||||
const relative = path.relative(root, file);
|
||||
stage = 'file_stat';
|
||||
const initial = lstatSync(input.file, { bigint: true });
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative) || !initial.isFile() || initial.isSymbolicLink()
|
||||
|| relative !== path.relative(root, path.resolve(input.file))) return { available: false, reason: 'outside_owned_fixture' };
|
||||
if (input.expectedIdentity !== undefined && (input.expectedIdentity?.dev !== initial.dev.toString()
|
||||
|| input.expectedIdentity?.ino !== initial.ino.toString())) return { available: false, reason: 'failed_object_identity_changed' };
|
||||
const unchanged = () => {
|
||||
stage = 'file_recheck';
|
||||
const current = lstatSync(input.file, { bigint: true });
|
||||
return current.isFile() && !current.isSymbolicLink() && current.dev === initial.dev && current.ino === initial.ino
|
||||
&& realpathSync(input.file) === file;
|
||||
};
|
||||
stage = 'load_restart_manager';
|
||||
const restart = dlopen(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'rstrtmgr.dll'), {
|
||||
RmStartSession: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmRegisterResources: { args: [FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmGetList: { args: [FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.u32 },
|
||||
RmEndSession: { args: [FFIType.u32], returns: FFIType.u32 },
|
||||
});
|
||||
stage = 'load_kernel';
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
GetProcessTimes: { args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
});
|
||||
let session: number | undefined;
|
||||
try {
|
||||
const sessionBuffer = Buffer.alloc(4);
|
||||
const key = Buffer.alloc(66);
|
||||
stage = 'session_start';
|
||||
let status = restart.symbols.RmStartSession(ptr(sessionBuffer), 0, ptr(key));
|
||||
if (status !== 0) return { available: false, reason: 'session_start', status };
|
||||
session = sessionBuffer.readUInt32LE(0);
|
||||
const wideFile = Buffer.from(file + '\0', 'utf16le');
|
||||
const names = Buffer.alloc(8);
|
||||
names.writeBigUInt64LE(BigInt(ptr(wideFile)));
|
||||
stage = 'register_file';
|
||||
status = restart.symbols.RmRegisterResources(session, 1, ptr(names), 0, null, 0, null);
|
||||
if (status !== 0) return { available: false, reason: 'register_file', status };
|
||||
const needed = Buffer.alloc(4);
|
||||
const count = Buffer.alloc(4);
|
||||
const rebootReasons = Buffer.alloc(4);
|
||||
stage = 'owner_count';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), null, ptr(rebootReasons));
|
||||
if (status === 0 && needed.readUInt32LE(0) === 0) return unchanged()
|
||||
? { available: true, owners: [], rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
const entries = needed.readUInt32LE(0);
|
||||
if (status !== 234 || entries < 1 || entries > 64) return { available: false, reason: 'owner_count', status, entries };
|
||||
const information = Buffer.alloc(entries * 668);
|
||||
count.writeUInt32LE(entries);
|
||||
stage = 'owner_list';
|
||||
status = restart.symbols.RmGetList(session, ptr(needed), ptr(count), ptr(information), ptr(rebootReasons));
|
||||
const returned = count.readUInt32LE(0);
|
||||
if (status !== 0 || returned > entries) return { available: false, reason: 'owner_list', status };
|
||||
const owners = [];
|
||||
stage = 'owner_identity';
|
||||
for (let index = 0; index < returned; index++) {
|
||||
const offset = index * 668;
|
||||
const pid = information.readUInt32LE(offset);
|
||||
const recordedStart = information.readBigUInt64LE(offset + 4);
|
||||
let image = 'unavailable';
|
||||
let creationMatched = false;
|
||||
const handle = kernel.symbols.OpenProcess(0x1000, 0, pid);
|
||||
if (handle) {
|
||||
try {
|
||||
const times = Buffer.alloc(32);
|
||||
const timeAddress = ptr(times);
|
||||
if (kernel.symbols.GetProcessTimes(handle, timeAddress, timeAddress + 8, timeAddress + 16, timeAddress + 24)) {
|
||||
creationMatched = times.readBigUInt64LE(0) === recordedStart;
|
||||
}
|
||||
if (creationMatched) {
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (kernel.symbols.QueryFullProcessImageNameW(handle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
const chars = imageLength.readUInt32LE(0);
|
||||
const name = chars <= 32768 ? path.basename(imageBuffer.subarray(0, chars * 2).toString('utf16le')).toLowerCase() : '';
|
||||
image = ['bun.exe', 'node.exe', 'msedge.exe', 'msmpeng.exe', 'mssense.exe', 'dllhost.exe', 'explorer.exe', 'powershell.exe', 'pwsh.exe', 'svchost.exe', 'conhost.exe'].includes(name) ? name : 'other';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
kernel.symbols.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
owners.push({ pid, image, creationMatched, isTestHost: creationMatched && pid === input.testPid, applicationType: information.readUInt32LE(offset + 652) });
|
||||
}
|
||||
return unchanged() ? { available: true, owners, rebootReasons: rebootReasons.readUInt32LE(0) }
|
||||
: { available: false, reason: 'failed_object_identity_changed' };
|
||||
} catch (error) {
|
||||
throw new FileOwnerProbeError(stage, error);
|
||||
} finally {
|
||||
stage = 'session_end';
|
||||
if (session !== undefined) restart.symbols.RmEndSession(session);
|
||||
stage = 'library_close';
|
||||
kernel.close(); restart.close();
|
||||
}
|
||||
}
|
||||
|
||||
let result: object;
|
||||
try { result = inspect(); }
|
||||
catch (error) {
|
||||
const failure = error instanceof FileOwnerProbeError ? error : new FileOwnerProbeError(stage, error);
|
||||
result = { available: false, reason: 'owner_query_failed', stage: failure.stage, errorCode: failure.errorCode };
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
const fs = require('node:fs');
|
||||
const cp = require('node:child_process');
|
||||
const { createHash } = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = ({ observation, playwrightEntry, mode = 'normal-close', inspectCommandLine = false, observerExecutable, seedCookie = { name: 'synthetic', value: 'synthetic', domain: 'example.test', path: '/' } }) => {
|
||||
if (inspectCommandLine && process.platform === 'win32' && typeof observerExecutable !== 'string') throw new Error('Native observer executable is required');
|
||||
const originalSpawn = cp.spawn;
|
||||
let inspected = Promise.resolve();
|
||||
let folderEvidence;
|
||||
const directoryState = (env, root) => ({
|
||||
requestedProfile: fs.existsSync(root),
|
||||
localEnvironment: fs.existsSync(env.LOCALAPPDATA || ''),
|
||||
roamingEnvironment: fs.existsSync(env.APPDATA || ''),
|
||||
localUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Local')),
|
||||
roamingUnderProfile: fs.existsSync(path.join(env.USERPROFILE || '', 'AppData', 'Roaming')),
|
||||
});
|
||||
const safeFolders = value => Object.fromEntries(['local', 'roaming'].map(name => [name,
|
||||
Object.fromEntries(['verified', 'dontVerify'].map(kind => {
|
||||
const item = value?.[name]?.[kind];
|
||||
return [kind, {
|
||||
hresult: Number.isInteger(item?.hresult) ? item.hresult : null,
|
||||
pathHash: /^[a-f0-9]{64}$/.test(item?.pathHash) ? item.pathHash : null,
|
||||
exists: typeof item?.exists === 'boolean' ? item.exists : null,
|
||||
matchesEnvironment: typeof item?.matchesEnvironment === 'boolean' ? item.matchesEnvironment : null,
|
||||
underUserProfile: typeof item?.underUserProfile === 'boolean' ? item.underUserProfile : null,
|
||||
}];
|
||||
})),
|
||||
]));
|
||||
const runProbe = (input, env) => new Promise(resolve => {
|
||||
const probe = originalSpawn(observerExecutable, [
|
||||
'--no-env-file', '--no-install', '--no-macros', '--config=NUL', path.join(__dirname, 'native-cookie-process-observer.ts'),
|
||||
Buffer.from(JSON.stringify(input)).toString('base64'),
|
||||
], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
||||
let output = '';
|
||||
let stderrBytes = 0;
|
||||
let spawnFailed = false;
|
||||
const timer = setTimeout(() => probe.kill(), 5_000);
|
||||
probe.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 16384) probe.kill(); });
|
||||
probe.stderr.on('data', chunk => { stderrBytes += chunk.length; });
|
||||
probe.once('error', () => { spawnFailed = true; });
|
||||
probe.once('close', code => {
|
||||
clearTimeout(timer);
|
||||
try { resolve({ measured: JSON.parse(output), code, stderrBytes }); }
|
||||
catch { resolve({ measured: { available: false, reason: spawnFailed ? 'probe_spawn_failed' : 'probe_no_receipt' }, code, stderrBytes }); }
|
||||
});
|
||||
});
|
||||
cp.spawn = function(command, args, options) {
|
||||
if (args.some(arg => /^--(?:no-sandbox|disable-setuid-sandbox)(?:=|$)/.test(arg))) throw new Error('Native fixture refuses a sandbox-disabled browser');
|
||||
const child = originalSpawn.call(this, command, args, options);
|
||||
const evidence = {
|
||||
command, args, pid: child.pid,
|
||||
argsHash: createHash('sha256').update(JSON.stringify(args)).digest('hex'),
|
||||
envHash: createHash('sha256').update(JSON.stringify(Object.entries(options.env || {}).sort(([a], [b]) => a.localeCompare(b)))).digest('hex'),
|
||||
stderrBytes: 0,
|
||||
reasons: [],
|
||||
runtime: { node: process.version, bun: process.versions.bun || null, architecture: process.arch },
|
||||
folderEvidence,
|
||||
};
|
||||
const publish = () => fs.writeFileSync(observation, JSON.stringify(evidence));
|
||||
publish();
|
||||
let tail = '';
|
||||
child.stderr?.on('data', chunk => {
|
||||
evidence.stderrBytes += chunk.length;
|
||||
if (evidence.stderrBytes > 65536) return;
|
||||
const text = tail + chunk.toString('utf8');
|
||||
const patterns = {
|
||||
job_assignment_failed: /AssignProcessToJobObject|failed to (?:assign|create).*job object/i,
|
||||
sandbox_failed: /sandbox.*(?:failed|error)|SBOX_FATAL/i,
|
||||
profile_locked: /ProcessSingleton|profile.*in use/i,
|
||||
default_profile_policy: /remote debugging requires a non-default data directory/i,
|
||||
permission_denied: /access is denied|ERROR_ACCESS_DENIED|permission denied/i,
|
||||
crashpad_failed: /crashpad.*(?:failed|error)/i,
|
||||
missing_dependency: /specified module could not be found|0xc0000135/i,
|
||||
};
|
||||
for (const [reason, pattern] of Object.entries(patterns)) {
|
||||
if (pattern.test(text) && !evidence.reasons.includes(reason)) evidence.reasons.push(reason);
|
||||
}
|
||||
tail = text.slice(-512);
|
||||
publish();
|
||||
});
|
||||
child.once('exit', (code, signal) => { evidence.exitCode = code; evidence.signal = signal; publish(); });
|
||||
child.once('error', error => {
|
||||
evidence.spawnError = ['ENOENT', 'EACCES', 'EPERM', 'EINVAL'].includes(error.code) ? error.code : 'spawn_failed';
|
||||
publish();
|
||||
});
|
||||
if (inspectCommandLine && process.platform === 'win32' && Number.isInteger(child.pid)) {
|
||||
inspected = runProbe({ pid: child.pid, owner: process.pid, image: command }, options.env).then(({ measured, code, stderrBytes }) => {
|
||||
const expectedHashes = args.map(arg => createHash('sha256').update(arg).digest('hex'));
|
||||
const dataDir = args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16);
|
||||
evidence.observedCommandLine = {
|
||||
available: measured.available === true,
|
||||
parentMatched: measured.parentMatched === true,
|
||||
imageMatched: measured.imageMatched === true,
|
||||
reason: ['not_windows', 'invalid_input', 'process_open', 'process_identity', 'owned_process_unavailable', 'command_line', 'command_line_length', 'command_line_bounds', 'argument_parse', 'argument_bounds', 'job_query', 'observer_initialize', 'probe_spawn_failed', 'probe_no_receipt'].includes(measured.reason) ? measured.reason : undefined,
|
||||
win32Error: Number.isInteger(measured.win32Error) ? measured.win32Error : undefined,
|
||||
ntStatus: Number.isInteger(measured.ntStatus) ? measured.ntStatus : undefined,
|
||||
commandLineHash: /^[a-f0-9]{64}$/.test(measured.commandLineHash) ? measured.commandLineHash : undefined,
|
||||
argumentsMatchRequested: measured.available === true && JSON.stringify(measured.argumentHashes) === JSON.stringify(expectedHashes),
|
||||
userDataDirCount: Number.isInteger(measured.userDataDirCount) && measured.userDataDirCount >= 0 && measured.userDataDirCount <= 128 ? measured.userDataDirCount : undefined,
|
||||
userDataDirMatchesRequested: typeof dataDir === 'string' && measured.userDataDirHash === createHash('sha256').update(dataDir).digest('hex'),
|
||||
pipePresent: measured.pipePresent === true,
|
||||
browserInJob: typeof measured.browserInJob === 'boolean' ? measured.browserInJob : undefined,
|
||||
observerJobLimitFlags: Number.isInteger(measured.observerJobLimitFlags) ? measured.observerJobLimitFlags : undefined,
|
||||
observerJobQueryError: Number.isInteger(measured.observerJobQueryError) ? measured.observerJobQueryError : undefined,
|
||||
exitCode: code, stderrBytes,
|
||||
};
|
||||
evidence.folderEvidence.afterLaunch = safeFolders(measured.knownFolders);
|
||||
evidence.folderEvidence.directoriesAfterLaunch = directoryState(options.env, args.find(arg => arg.startsWith('--user-data-dir='))?.slice(16) || '');
|
||||
publish();
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
const { chromium } = require(playwrightEntry);
|
||||
return { chromium: { async launchPersistentContext(root, options) {
|
||||
if (options.chromiumSandbox !== true) throw new Error('Native fixture requires the browser sandbox');
|
||||
const started = Date.now();
|
||||
if (inspectCommandLine && process.platform === 'win32') {
|
||||
const directoriesBefore = directoryState(options.env, root);
|
||||
const before = await runProbe({ mode: 'known-folders' }, options.env);
|
||||
folderEvidence = { beforeLaunch: safeFolders(before.measured.knownFolders), directoriesBefore, directoriesAfterProbe: directoryState(options.env, root) };
|
||||
}
|
||||
const context = await chromium.launchPersistentContext(root, inspectCommandLine && process.platform === 'win32'
|
||||
? { ...options, timeout: Math.max(1, options.timeout - (Date.now() - started)) } : options);
|
||||
await inspected;
|
||||
await context.addCookies([seedCookie]);
|
||||
if (mode === 'stalled-close') context.close = () => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); };
|
||||
return context;
|
||||
} } };
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dlopen, FFIType, ptr, toArrayBuffer } from 'bun:ffi';
|
||||
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
|
||||
export function decodeNativeCommandLine(buffer: Buffer, address: number | bigint): string | null {
|
||||
if (buffer.length < 16) return null;
|
||||
const length = buffer.readUInt16LE(0);
|
||||
const offset = Number(buffer.readBigUInt64LE(8) - BigInt(address));
|
||||
if (!Number.isSafeInteger(offset) || offset < 16 || offset + length > buffer.length || length % 2 !== 0) return null;
|
||||
return buffer.subarray(offset, offset + length).toString('utf16le');
|
||||
}
|
||||
|
||||
function knownFolders() {
|
||||
const shell = dlopen('shell32.dll', {
|
||||
SHGetFolderPathW: { args: [FFIType.u64, FFIType.i32, FFIType.u64, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const normalized = (value: string) => path.win32.normalize(value).toLowerCase();
|
||||
try {
|
||||
return Object.fromEntries([['local', 0x1c, 'LOCALAPPDATA'], ['roaming', 0x1a, 'APPDATA']].map(([name, id, env]) => {
|
||||
const calls = Object.fromEntries([['verified', 0], ['dontVerify', 0x4000]].map(([kind, flag]) => {
|
||||
const output = Buffer.alloc(520);
|
||||
const status = shell.symbols.SHGetFolderPathW(0, Number(id) | Number(flag), 0, 0, ptr(output));
|
||||
let end = 0;
|
||||
while (end + 2 <= output.length && output.readUInt16LE(end) !== 0) end += 2;
|
||||
const folder = status >= 0 && end > 0 && end + 2 <= output.length ? output.subarray(0, end).toString('utf16le') : null;
|
||||
return [kind, {
|
||||
hresult: status,
|
||||
pathHash: folder ? hash(normalized(folder)) : null,
|
||||
exists: folder ? existsSync(folder) : null,
|
||||
matchesEnvironment: folder ? normalized(folder) === normalized(process.env[String(env)] || '') : null,
|
||||
underUserProfile: folder ? normalized(folder).startsWith(normalized(process.env.USERPROFILE || '') + '\\') : null,
|
||||
}];
|
||||
}));
|
||||
return [name, calls];
|
||||
}));
|
||||
} finally {
|
||||
shell.close();
|
||||
}
|
||||
}
|
||||
|
||||
function observe() {
|
||||
if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) return { available: false, reason: 'not_windows' };
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
if (input.mode === 'known-folders') return { available: true, knownFolders: knownFolders() };
|
||||
if (!Number.isSafeInteger(input.pid) || input.pid <= 0 || input.pid > 0xffffffff || !Number.isSafeInteger(input.owner) || input.owner <= 0 || input.owner > 0xffffffff || typeof input.image !== 'string' || input.image.length > 32768) {
|
||||
return { available: false, reason: 'invalid_input' };
|
||||
}
|
||||
const kernel = dlopen('kernel32.dll', {
|
||||
OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.u64 },
|
||||
CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 },
|
||||
QueryFullProcessImageNameW: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
||||
QueryInformationJobObject: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
IsProcessInJob: { args: [FFIType.u64, FFIType.u64, FFIType.ptr], returns: FFIType.i32 },
|
||||
LocalFree: { args: [FFIType.ptr], returns: FFIType.ptr },
|
||||
LocalSize: { args: [FFIType.ptr], returns: FFIType.u64 },
|
||||
GetLastError: { args: [], returns: FFIType.u32 },
|
||||
});
|
||||
const nt = dlopen('ntdll.dll', {
|
||||
NtQueryInformationProcess: { args: [FFIType.u64, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
||||
});
|
||||
const shell = dlopen('shell32.dll', {
|
||||
CommandLineToArgvW: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.ptr },
|
||||
});
|
||||
let processHandle: number | bigint = 0;
|
||||
let argumentMemory: ReturnType<typeof shell.symbols.CommandLineToArgvW> = null;
|
||||
let stage = 'process_open';
|
||||
try {
|
||||
processHandle = kernel.symbols.OpenProcess(0x1000, 0, input.pid);
|
||||
if (!processHandle) return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
stage = 'process_identity';
|
||||
const basic = Buffer.alloc(48);
|
||||
const returned = Buffer.alloc(4);
|
||||
let status = nt.symbols.NtQueryInformationProcess(processHandle, 0, ptr(basic), basic.length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const parentMatched = basic.readBigUInt64LE(40) === BigInt(input.owner);
|
||||
const pidMatched = basic.readBigUInt64LE(32) === BigInt(input.pid);
|
||||
const imageBuffer = Buffer.alloc(65536);
|
||||
const imageLength = Buffer.alloc(4);
|
||||
imageLength.writeUInt32LE(32768);
|
||||
if (!kernel.symbols.QueryFullProcessImageNameW(processHandle, 0, ptr(imageBuffer), ptr(imageLength))) {
|
||||
return { available: false, reason: stage, win32Error: kernel.symbols.GetLastError() };
|
||||
}
|
||||
const imageChars = imageLength.readUInt32LE(0);
|
||||
const imageMatched = imageChars <= 32768 && imageBuffer.subarray(0, imageChars * 2).toString('utf16le').toLowerCase() === input.image.toLowerCase();
|
||||
if (!parentMatched || !pidMatched || !imageMatched) return { available: false, reason: 'owned_process_unavailable', parentMatched, imageMatched };
|
||||
stage = 'command_line';
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, null, 0, ptr(returned));
|
||||
const length = returned.readUInt32LE(0);
|
||||
if (length < 16 || length > 131072) return { available: false, reason: 'command_line_length', ntStatus: status };
|
||||
const commandBuffer = Buffer.alloc(length);
|
||||
const commandAddress = ptr(commandBuffer);
|
||||
status = nt.symbols.NtQueryInformationProcess(processHandle, 60, commandAddress, length, ptr(returned));
|
||||
if (status !== 0) return { available: false, reason: stage, ntStatus: status };
|
||||
const commandLine = decodeNativeCommandLine(commandBuffer, commandAddress);
|
||||
if (commandLine === null) return { available: false, reason: 'command_line_bounds' };
|
||||
stage = 'argument_parse';
|
||||
const wideCommand = Buffer.from(commandLine + '\0', 'utf16le');
|
||||
const count = Buffer.alloc(4);
|
||||
argumentMemory = shell.symbols.CommandLineToArgvW(ptr(wideCommand), ptr(count));
|
||||
const argumentCount = count.readInt32LE(0);
|
||||
if (!argumentMemory || argumentCount < 1 || argumentCount > 4096) return { available: false, reason: stage };
|
||||
const size = Number(kernel.symbols.LocalSize(argumentMemory));
|
||||
if (!Number.isSafeInteger(size) || size < argumentCount * 8 || size > 1048576) return { available: false, reason: 'argument_bounds' };
|
||||
const argumentsBuffer = Buffer.from(toArrayBuffer(argumentMemory, 0, size));
|
||||
const args: string[] = [];
|
||||
for (let index = 1; index < argumentCount; index++) {
|
||||
const start = Number(argumentsBuffer.readBigUInt64LE(index * 8) - BigInt(argumentMemory));
|
||||
if (!Number.isSafeInteger(start) || start < argumentCount * 8 || start % 2 !== 0 || start >= size) return { available: false, reason: 'argument_bounds' };
|
||||
let end = start;
|
||||
while (end + 2 <= size && argumentsBuffer.readUInt16LE(end) !== 0) end += 2;
|
||||
if (end + 2 > size) return { available: false, reason: 'argument_bounds' };
|
||||
args.push(argumentsBuffer.subarray(start, end).toString('utf16le'));
|
||||
}
|
||||
stage = 'job_query';
|
||||
const limits = Buffer.alloc(144);
|
||||
const jobKnown = kernel.symbols.QueryInformationJobObject(0, 9, ptr(limits), limits.length, ptr(returned));
|
||||
const jobError = jobKnown ? undefined : kernel.symbols.GetLastError();
|
||||
const inJob = Buffer.alloc(4);
|
||||
const membershipKnown = kernel.symbols.IsProcessInJob(processHandle, 0, ptr(inJob));
|
||||
const dataArgs = args.filter(arg => arg.startsWith('--user-data-dir='));
|
||||
return {
|
||||
available: true, parentMatched, imageMatched,
|
||||
commandLineHash: hash(commandLine), argumentHashes: args.map(hash),
|
||||
userDataDirCount: dataArgs.length,
|
||||
userDataDirHash: dataArgs.length === 1 ? hash(dataArgs[0].slice(16)) : null,
|
||||
pipePresent: args.includes('--remote-debugging-pipe'),
|
||||
browserInJob: membershipKnown ? inJob.readInt32LE(0) !== 0 : null,
|
||||
observerJobLimitFlags: jobKnown ? limits.readUInt32LE(16) : null,
|
||||
observerJobQueryError: jobError,
|
||||
knownFolders: knownFolders(),
|
||||
};
|
||||
} catch {
|
||||
return { available: false, reason: stage };
|
||||
} finally {
|
||||
if (argumentMemory) kernel.symbols.LocalFree(argumentMemory);
|
||||
if (processHandle) kernel.symbols.CloseHandle(processHandle);
|
||||
shell.close(); nt.close(); kernel.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
let result: object;
|
||||
let exitCode = 0;
|
||||
try { result = observe(); }
|
||||
catch { result = { available: false, reason: 'observer_initialize' }; exitCode = 1; }
|
||||
process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(exitCode));
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = ({ pidsFile, mode }) => ({
|
||||
chromium: {
|
||||
async launchPersistentContext() {
|
||||
const child = require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
require('node:fs').writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
||||
if (mode === 'worker-crash') setTimeout(() => process.exit(3), 100);
|
||||
await new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
let stage = 'input';
|
||||
let root;
|
||||
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
try {
|
||||
const input = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));
|
||||
root = input.root;
|
||||
if (typeof root !== 'string' || typeof input.realpath !== 'string' || typeof input.dev !== 'string' || typeof input.ino !== 'string') throw new Error('invalid_input');
|
||||
stage = 'identity';
|
||||
const state = fs.lstatSync(root, { bigint: true });
|
||||
const identity = {
|
||||
directory: state.isDirectory() && !state.isSymbolicLink(),
|
||||
pathMatches: samePath(fs.realpathSync(root), input.realpath),
|
||||
deviceMatches: state.dev.toString() === input.dev,
|
||||
inodeMatches: state.ino.toString() === input.ino,
|
||||
};
|
||||
if (Object.values(identity).some(value => !value)) {
|
||||
console.log(JSON.stringify({ removed: false, reason: 'identity_mismatch', identity }));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
stage = 'remove';
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 0 });
|
||||
console.log(JSON.stringify({ removed: !fs.existsSync(root), identity, retries: 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['EPERM', 'EACCES', 'EBUSY', 'ENOENT', 'EINVAL', 'ENOTEMPTY', 'ENOTDIR'].includes(error.code) ? error.code : 'filesystem_error';
|
||||
let failingPath = null;
|
||||
let metadata = null;
|
||||
if (typeof root === 'string' && typeof error.path === 'string') {
|
||||
const relative = path.relative(root, error.path);
|
||||
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
||||
failingPath = relative || '.';
|
||||
try {
|
||||
const state = fs.lstatSync(error.path);
|
||||
metadata = { mode: state.mode, directory: state.isDirectory(), link: state.isSymbolicLink() };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ removed: false, stage, code, failingPath, metadata }));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user