diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 63e1fcdc79..2ebfcc5025 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -182,6 +182,15 @@ If cleanup cannot be verified, the error retains collected observations in `details.partial_observation` and marks their application cleanup `unverified`. Cleanup uncertainty does not authorize signaling unrelated processes. +On Linux, Chromium-based processes can overwrite the initial environment memory +exposed through `/proc//environ`. The environment may be readable while +`REA_PROCESS_RUN_ID` is absent, even if the application still sees it through +`process.env`. REA reports the missing marker and leaves lineage ownership and +cleanup unverified; the PID, executable, or parent relationship cannot replace +that ownership proof. This limitation can affect owned Electron captures on +native Linux as well as WSL. It does not establish that all Electron versions or +Linux environments behave the same way. + Main-process IPC recording keeps original-function removal for `on` and `once` registrations and records a fired `once` callback once. Native Node.js owns `once` registration, firing and callable dispatch as well as removal order and diff --git a/src/process/ProcessOwnership.lineage.test.ts b/src/process/ProcessOwnership.lineage.test.ts index ba13724062..5a07a0f88c 100644 --- a/src/process/ProcessOwnership.lineage.test.ts +++ b/src/process/ProcessOwnership.lineage.test.ts @@ -43,6 +43,52 @@ const nativeIdentity = ( }, }); +it.each( + [false, true].flatMap((native) => + ([undefined, "foreign"] as const).map((runId) => ({ native, runId })), + ), +)( + "keeps missing ownership distinct from an observed foreign token, native=$native, runId=$runId", + async ({ native, runId }) => { + const signalGroup = vi.fn(); + const host: ProcessOwnershipHost = { + platform: "linux", + listProcesses: async () => [launcher], + environment: async () => + runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }, + ...(native + ? { + processIdentities: async (rows: readonly ProcessTableEntry[]) => + new Map(rows.map((row) => [row.pid, nativeIdentity(row)])), + } + : {}), + signalGroup, + }; + const ownership = { + runId: "owned", + leaderPid: launcher.pid, + processGroupId: launcher.processGroupId, + expectedParentPid: launcher.parentPid, + expectedCommand: launcher.command, + }; + await expect( + observeOwnedProcessLineage(ownership, host), + ).resolves.toMatchObject({ + status: "unavailable", + reason: + runId === undefined + ? "REA_PROCESS_RUN_ID was absent from the readable environment for PID 100; process lineage ownership remains unverified" + : "process lineage contains an unowned or PID-reused process", + }); + await expect( + cleanupOwnedProcessGroup(ownership, host), + ).resolves.toMatchObject({ + cleaned: false, + }); + expect(signalGroup).not.toHaveBeenCalled(); + }, +); + it.each( (["parent", "group", "command"] as const).flatMap((mismatch) => ( diff --git a/src/process/ProcessOwnershipObservation.ts b/src/process/ProcessOwnershipObservation.ts index 11aac8dee2..d3b43c36c9 100644 --- a/src/process/ProcessOwnershipObservation.ts +++ b/src/process/ProcessOwnershipObservation.ts @@ -275,7 +275,7 @@ export const observeOwnedProcessLineage = async ( if (observation.runId !== ownership.runId) return unavailableLineage( ownership, - "process lineage contains an unowned or PID-reused process", + lineageTokenMismatchReason(member.pid, observation.runId), ); if (member.pid !== launcher.pid) verifiedDescendants.push(member); const seeded = examinedRefresh.get(member.pid); @@ -476,7 +476,7 @@ const observeNativeOwnedProcessLineage = async ( if (fact.token.runId !== ownership.runId) return unavailableLineage( ownership, - "process lineage contains an unowned or PID-reused process", + lineageTokenMismatchReason(member.pid, fact.token.runId), ); const nativeMember = processWithNativeRelation( fact.process, @@ -585,7 +585,7 @@ const classifyIdentitylessLineageRefresh = async ( if (observation.runId !== ownership.runId) return { status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: lineageTokenMismatchReason(launcherPid, observation.runId), }; } const identityFailure = launcherIdentityFailure(refreshedLauncher, ownership); @@ -621,7 +621,7 @@ const classifyIdentitylessLineageRefresh = async ( if (inTree.has(process.pid)) return { status: "unavailable", - reason: "process lineage contains an unowned or PID-reused process", + reason: lineageTokenMismatchReason(process.pid, observation.runId), }; continue; } @@ -679,6 +679,14 @@ const processWithNativeRelation = ( } : undefined; +const lineageTokenMismatchReason = ( + pid: number, + observedRunId: string | undefined, +): string => + observedRunId === undefined + ? `REA_PROCESS_RUN_ID was absent from the readable environment for PID ${String(pid)}; process lineage ownership remains unverified` + : "process lineage contains an unowned or PID-reused process"; + const unavailableLineage = ( ownership: OwnedProcessGroup, reason: string,