|
| 1 | +import { createHash } from "node:crypto"; |
| 2 | +import { |
| 3 | + closeSync, |
| 4 | + constants, |
| 5 | + fstatSync, |
| 6 | + lstatSync, |
| 7 | + openSync, |
| 8 | + readSync, |
| 9 | + realpathSync, |
| 10 | +} from "node:fs"; |
| 11 | +import { fileURLToPath } from "node:url"; |
| 12 | + |
| 13 | +const SOURCE_SHA256 = |
| 14 | + "8913d0e1f1390b443e04bfb8264f16565f3c2a482c64c6235bb74b645c81c56a"; |
| 15 | +const SOURCE_PATH = fileURLToPath( |
| 16 | + new URL("../../src/lib/native-compose-ownership.ts", import.meta.url) |
| 17 | +); |
| 18 | +const SOURCE_BUDGET = 128 * 1024; |
| 19 | +const HEADER = |
| 20 | + "NativeComposeOwnershipError: Native Compose resource ownership is missing, conflicting or changed; values omitted."; |
| 21 | +const CALLSITES = [ |
| 22 | + ["collectInspections", 602, "resource-owner-labels"], |
| 23 | + ["collectInspections", 660, "network-policy"], |
| 24 | + ["endpointAliases", 745, "endpoint-alias-shape"], |
| 25 | + ["validateEndpointIdentity", 785, "endpoint-network-id"], |
| 26 | + ["validateEndpointIdentity", 787, "endpoint-aliases"], |
| 27 | + ["requireLiveMember", 862, "live-network-membership"], |
| 28 | +] as const; |
| 29 | +export type ProcessPolicyReplayReason = |
| 30 | + | (typeof CALLSITES)[number][2] |
| 31 | + | "unavailable"; |
| 32 | +type SourcePin = { |
| 33 | + readonly path: string; |
| 34 | + readonly sha256: string; |
| 35 | + readonly identity: string; |
| 36 | +}; |
| 37 | + |
| 38 | +/** Diagnostic evidence only: no probe, admission or cleanup authority. */ |
| 39 | +export function captureProcessPolicyOwnershipSource(): SourcePin | null { |
| 40 | + let fd: number | undefined; |
| 41 | + try { |
| 42 | + if (realpathSync(SOURCE_PATH) !== SOURCE_PATH) { |
| 43 | + return null; |
| 44 | + } |
| 45 | + fd = openSync( |
| 46 | + SOURCE_PATH, |
| 47 | + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK |
| 48 | + ); |
| 49 | + const before = fstatSync(fd); |
| 50 | + if (!(before.isFile() && before.size > 0 && before.size <= SOURCE_BUDGET)) { |
| 51 | + return null; |
| 52 | + } |
| 53 | + const bytes = Buffer.alloc(before.size + 1); |
| 54 | + let length = 0; |
| 55 | + while (length < bytes.length) { |
| 56 | + const count = readSync(fd, bytes, length, bytes.length - length, length); |
| 57 | + if (count === 0) { |
| 58 | + break; |
| 59 | + } |
| 60 | + length += count; |
| 61 | + } |
| 62 | + const after = fstatSync(fd); |
| 63 | + const identity = (value: typeof before): string => |
| 64 | + JSON.stringify([ |
| 65 | + value.dev, |
| 66 | + value.ino, |
| 67 | + value.size, |
| 68 | + value.mtimeMs, |
| 69 | + value.ctimeMs, |
| 70 | + ]); |
| 71 | + if ( |
| 72 | + length !== before.size || |
| 73 | + identity(before) !== identity(after) || |
| 74 | + realpathSync(SOURCE_PATH) !== SOURCE_PATH |
| 75 | + ) { |
| 76 | + return null; |
| 77 | + } |
| 78 | + // The descriptor must still name the current source, not a replaced path. |
| 79 | + const current = lstatSync(SOURCE_PATH); |
| 80 | + if (!current.isFile() || identity(current) !== identity(after)) { |
| 81 | + return null; |
| 82 | + } |
| 83 | + const sha256 = createHash("sha256") |
| 84 | + .update(bytes.subarray(0, length)) |
| 85 | + .digest("hex"); |
| 86 | + return sha256 === SOURCE_SHA256 |
| 87 | + ? { path: SOURCE_PATH, sha256, identity: identity(after) } |
| 88 | + : null; |
| 89 | + } catch { |
| 90 | + return null; |
| 91 | + } finally { |
| 92 | + if (fd !== undefined) { |
| 93 | + try { |
| 94 | + closeSync(fd); |
| 95 | + } catch { |
| 96 | + // Diagnostic unavailability must not replace the replay's ownership result. |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +export function sameProcessPolicyOwnershipSource( |
| 103 | + before: SourcePin | null, |
| 104 | + after: SourcePin | null |
| 105 | +): boolean { |
| 106 | + return ( |
| 107 | + before !== null && |
| 108 | + after !== null && |
| 109 | + before.path === after.path && |
| 110 | + before.sha256 === after.sha256 && |
| 111 | + before.identity === after.identity |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +export function isProcessPolicyReplayReason( |
| 116 | + value: unknown |
| 117 | +): value is ProcessPolicyReplayReason { |
| 118 | + return ( |
| 119 | + value === "unavailable" || CALLSITES.some((entry) => entry[2] === value) |
| 120 | + ); |
| 121 | +} |
| 122 | + |
| 123 | +/** The first owning caller must match; never search later frames for a plausible reason. */ |
| 124 | +export function processPolicyReplayReason(opts: { |
| 125 | + readonly stack: unknown; |
| 126 | + readonly sourcePath: string; |
| 127 | + readonly sourceSha256: unknown; |
| 128 | +}): ProcessPolicyReplayReason { |
| 129 | + if ( |
| 130 | + opts.sourceSha256 !== SOURCE_SHA256 || |
| 131 | + opts.sourcePath !== SOURCE_PATH || |
| 132 | + typeof opts.stack !== "string" || |
| 133 | + opts.stack.length > 16 * 1024 |
| 134 | + ) { |
| 135 | + return "unavailable"; |
| 136 | + } |
| 137 | + const frames = opts.stack.split("\n"); |
| 138 | + const matches = ( |
| 139 | + frame: string | undefined, |
| 140 | + name: string, |
| 141 | + line: number |
| 142 | + ): boolean => { |
| 143 | + const prefix = ` at ${name} (${SOURCE_PATH}:${line}:`; |
| 144 | + if (!(frame?.startsWith(prefix) && frame.endsWith(")"))) { |
| 145 | + return false; |
| 146 | + } |
| 147 | + return /^[1-9][0-9]{0,5}$/.test(frame.slice(prefix.length, -1)); |
| 148 | + }; |
| 149 | + if ( |
| 150 | + frames[0] !== HEADER || |
| 151 | + !matches(frames[1], "refuse", 200) || |
| 152 | + !matches(frames[2], "requireValue", 204) |
| 153 | + ) { |
| 154 | + return "unavailable"; |
| 155 | + } |
| 156 | + return ( |
| 157 | + CALLSITES.find(([name, line]) => matches(frames[3], name, line))?.[2] ?? |
| 158 | + "unavailable" |
| 159 | + ); |
| 160 | +} |
0 commit comments