Parse procfs argument boundaries rather than searching rendered ps command text. Keep executable identity checks and deferred permission failures, eliminate the extra ps process, and put shared process observations in the serial process-boundary lane.
71 lines
2.8 KiB
JavaScript
71 lines
2.8 KiB
JavaScript
import { readFile, readdir, stat } from "node:fs/promises";
|
|
import { basename } from "node:path";
|
|
|
|
/**
|
|
* Observe leftover demo processes on a dedicated clean Linux runner, including
|
|
* groups reparented after their launcher exits. This is not ownership proof and
|
|
* never authorizes signaling; unrelated runtime processes also block this lane.
|
|
*/
|
|
export const assertCleanLinuxHopperRuntime = async (
|
|
launcherPaths = [
|
|
"/opt/hopper/bin/Hopper",
|
|
...(process.env.HOPPER_LAUNCHER_PATH === undefined
|
|
? []
|
|
: [process.env.HOPPER_LAUNCHER_PATH]),
|
|
],
|
|
) => {
|
|
const launchers = [];
|
|
for (const path of launcherPaths) {
|
|
try {
|
|
launchers.push(await stat(path, { bigint: true }));
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
|
// Compare file identity, not a display path or a process name.
|
|
let inspectionError;
|
|
for (const entry of await readdir("/proc", { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
|
let retainedRuntime;
|
|
try {
|
|
// procfs preserves argument boundaries. A caller merely mentioning a
|
|
// runtime in its script text or other arguments is not that runtime.
|
|
const arguments_ = (
|
|
await readFile(`/proc/${entry.name}/cmdline`, "utf8")
|
|
).split("\0");
|
|
retainedRuntime =
|
|
arguments_[0] === "/opt/hopper/bin/Hopper" ||
|
|
basename(arguments_[0] ?? "") === "Xvfb" ||
|
|
(basename(arguments_[0] ?? "") === "python3" &&
|
|
basename(arguments_[1] ?? "") === "hopper-demo-x11.py");
|
|
if (!retainedRuntime && launchers.length > 0) {
|
|
const executable = await stat(`/proc/${entry.name}/exe`, {
|
|
bigint: true,
|
|
});
|
|
retainedRuntime = launchers.some(
|
|
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
|
// Other users' process facts may be unreadable. The setup lane launches
|
|
// providers as the caller; unreadable caller-owned processes fail closed.
|
|
if (error?.code === "EACCES") {
|
|
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
|
|
if (cause?.code === "ENOENT") return undefined;
|
|
throw cause;
|
|
});
|
|
if (owner === undefined || owner.uid !== process.getuid()) continue;
|
|
}
|
|
// Finish the inventory so an unrelated restricted process cannot mask a
|
|
// positively identified leak. Unknown caller processes still fail closed.
|
|
inspectionError ??= error;
|
|
continue;
|
|
}
|
|
if (retainedRuntime)
|
|
throw new Error("Clean Linux runner retained a Hopper demo process");
|
|
}
|
|
if (inspectionError !== undefined) throw inspectionError;
|
|
};
|