From 31b99306aa67e7840ae624d5a8e8a958ae600b4f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:26 +0800 Subject: [PATCH 01/18] fix(providers): retain composite cleanup failures and probe child liveness --- .../binary/BinarySessionLiveness.test.ts | 36 ++++- .../binary/CompositeProvider.test.ts | 129 ++++++++++++++++++ src/application/binary/CompositeProvider.ts | 127 +++++++++++++---- 3 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 src/application/binary/CompositeProvider.test.ts diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 88915f4c5..92199de5c 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -116,9 +116,7 @@ describe("BinarySession open liveness", () => { expect(deep.created).toHaveLength(2); }); - it("falls back to the health probe when the child reports no operation health", async () => { - // Without an operation-health hook there is no child-liveness signal, - // so the probe keeps the previous `health` admission behavior. + it("probes instantiated children through health when snapshots are absent", async () => { const { session, deep } = openSession({ operationHealth: false }); expect((await session.open(process.execPath)).ok).toBe(true); expect((await session.execute("address_name", {})).ok).toBe(true); @@ -126,9 +124,33 @@ describe("BinarySession open liveness", () => { const second = await session.open(process.execPath); expect(second.ok).toBe(true); - // The composite's `health` answers ok, so the live client is reused. + // The composite probes this already-instantiated child rather than + // answering health without contacting it. expect(deep.created).toHaveLength(1); expect(deep.created[0]?.closeCalled).toBe(false); + expect(deep.created[0]?.healthCalls).toBe(1); + }); + + it("retires a dead no-snapshot child after its health probe fails", async () => { + const { session, deep } = openSession({ operationHealth: false }); + expect((await session.open(process.execPath)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(true); + const dead = deep.created[0]; + dead?.kill(); + + expect((await session.open(process.execPath)).ok).toBe(true); + expect(dead?.healthCalls).toBe(1); + expect(dead?.closeCalled).toBe(true); + expect(deep.created).toHaveLength(1); + expect((await session.execute("address_name", {})).ok).toBe(true); + expect(deep.created).toHaveLength(2); + }); + + it("does not instantiate a child to answer health before first use", async () => { + const { session, deep } = openSession({ operationHealth: false }); + expect((await session.open(process.execPath)).ok).toBe(true); + expect((await session.open(process.execPath)).ok).toBe(true); + expect(deep.created).toEqual([]); }); it("reports cancellation without touching the session when aborted mid-probe", async () => { @@ -167,6 +189,7 @@ describe("BinarySession open liveness", () => { interface KillableChild extends AnalysisClient { kill(state?: "exited" | "unreachable" | "unknown" | "not_started"): void; closeCalled: boolean; + healthCalls: number; } const deepIdentity: ProviderIdentity = { @@ -269,10 +292,12 @@ const killableDeepProvider = (options: { createClient: (): KillableChild => { let alive = true; let closeCalled = false; + let healthCalls = 0; let deadState: "exited" | "unreachable" | "unknown" | "not_started" = "exited"; const client: KillableChild = { execute: (operation) => { + if (operation === "health") healthCalls += 1; return alive ? Promise.resolve( ok( @@ -313,6 +338,9 @@ const killableDeepProvider = (options: { get closeCalled() { return closeCalled; }, + get healthCalls() { + return healthCalls; + }, kill: ( state: | "exited" diff --git a/src/application/binary/CompositeProvider.test.ts b/src/application/binary/CompositeProvider.test.ts new file mode 100644 index 000000000..4e94e0891 --- /dev/null +++ b/src/application/binary/CompositeProvider.test.ts @@ -0,0 +1,129 @@ +import { expect, it, vi } from "vitest"; + +import { + createAnalysisExecution, + type AnalysisClient, + type AnalysisOperation, + type AnalysisProvider, + type CapabilityDescriptor, + type ProviderIdentity, +} from "../AnalysisProvider.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { err, ok } from "../../domain/result.js"; +import { CompositeProvider } from "./CompositeProvider.js"; + +const identity = (id: string): ProviderIdentity => ({ + id, + name: id, + version: "1", +}); + +const operationCapability = ( + provider: ProviderIdentity, + operation: Exclude, +): CapabilityDescriptor => ({ + provider, + operation, + available: true, + reason: null, + effects: { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], +}); + +const failingProvider = ( + id: string, + operation: Exclude, + resource: string, +) => { + const providerIdentity = identity(id); + const cleanupError = new ProviderCleanupError(id, [resource], { + reason: `${id} cleanup is unresolved`, + child_marker: `${id}-detail`, + }); + const close = vi.fn(async () => err(cleanupError)); + const provider: AnalysisProvider = { + identity: () => providerIdentity, + capabilities: () => [operationCapability(providerIdentity, operation)], + createClient: (): AnalysisClient => ({ + execute: (name) => + Promise.resolve( + ok(createAnalysisExecution(`${id}:${name}`, providerIdentity)), + ), + close, + }), + }; + return { provider, close, cleanupError }; +}; + +const executableTarget = { + path: "/tmp/fixture", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], +} as const; + +it("retains every failed child resource across composite cleanup retries", async () => { + const left = failingProvider("provider-left", "address_name", "left-db"); + const right = failingProvider("provider-right", "procedure_info", "right-db"); + const client = new CompositeProvider([ + left.provider, + right.provider, + ]).createClient(executableTarget); + + expect((await client.execute("address_name", {})).ok).toBe(true); + expect((await client.execute("procedure_info", {})).ok).toBe(true); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const closed = await client.close(); + expect(closed.ok).toBe(false); + if (closed.ok) throw new Error("Expected composite cleanup failure"); + expect(closed.error).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: ["left-db", "right-db"], + diagnostics: { + failures: [ + { provider_id: "provider-left", resources: ["left-db"] }, + { provider_id: "provider-right", resources: ["right-db"] }, + ], + }, + }); + expect(closed.error).toBeInstanceOf(ProviderCleanupError); + expect(closed.error.diagnostics).toMatchObject({ + failures: [ + { + diagnostics: { reason: "provider-left cleanup is unresolved" }, + }, + { + diagnostics: { reason: "provider-right cleanup is unresolved" }, + }, + ], + }); + } + + expect(left.close).toHaveBeenCalledTimes(2); + expect(right.close).toHaveBeenCalledTimes(2); +}); + +it("preserves one child's typed cleanup error unchanged", async () => { + const child = failingProvider("provider-only", "address_name", "one-db"); + const client = new CompositeProvider([child.provider]).createClient( + executableTarget, + ); + expect((await client.execute("address_name", {})).ok).toBe(true); + + const closed = await client.close(); + + expect(closed.ok).toBe(false); + if (closed.ok) throw new Error("Expected child cleanup failure"); + expect(closed.error).toBe(child.cleanupError); +}); diff --git a/src/application/binary/CompositeProvider.ts b/src/application/binary/CompositeProvider.ts index 05184d279..faf3f8705 100644 --- a/src/application/binary/CompositeProvider.ts +++ b/src/application/binary/CompositeProvider.ts @@ -1,4 +1,6 @@ import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { err, ok } from "../../domain/result.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; @@ -7,6 +9,7 @@ import type { AnalysisClientContext, AnalysisProvider, CapabilityDescriptor, + ExecutionOptions, ProviderIdentity, } from "../AnalysisProvider.js"; import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; @@ -24,6 +27,88 @@ const compositeProviderIdentity = ( version: null, }); +const closeCompositeClients = + ( + identity: ProviderIdentity, + clients: ReadonlyMap, + ): AnalysisClient["close"] => + async (options) => { + const outcomes = await Promise.all( + [...clients.entries()].map(([provider, client]) => + closeAnalysisClient(client, provider.identity().id, options).then( + (result) => ({ providerId: provider.identity().id, result }), + ), + ), + ); + const failures = outcomes.flatMap(({ providerId, result }) => + result.ok ? [] : [{ providerId, error: result.error }], + ); + if (failures.length === 0) return ok(null); + if (failures.length === 1) { + const failure = failures[0]; + if (failure !== undefined) return err(failure.error); + } + const resources = [ + ...new Set(failures.flatMap(({ error }) => error.cleanupResources)), + ]; + return err( + new ProviderCleanupError( + identity.id, + resources, + { + reason: + "Multiple composed provider clients could not be confirmed closed.", + failures: failures.map(({ providerId, error }) => ({ + provider_id: providerId, + message: error.message, + resources: [...error.cleanupResources], + ...(error.diagnostics === undefined + ? {} + : { diagnostics: error.diagnostics }), + })), + }, + { + cause: new AggregateError( + failures.map(({ error }) => error), + "Composed provider cleanup failed.", + ), + }, + ), + ); + }; + +const executeCompositeHealth = async ( + identity: ProviderIdentity, + clients: ReadonlyMap, + options: ExecutionOptions | undefined, +): ReturnType => { + for (const [provider, client] of clients) { + const health = client.operationHealthSnapshot?.(); + if ( + health !== undefined && + health.state !== "idle" && + health.state !== "busy" + ) + return err( + new ProviderAdapterError(provider.identity().id, "health", { + diagnostics: { + reason: `instantiated provider client is ${health.state}`, + }, + }), + ); + const checked = await client.execute("health", {}, options); + if (!checked.ok) return checked; + } + return ok({ + result: null, + rawResult: null, + provider: identity, + limitations: [], + locations: [], + subject: null, + }); +}; + /** Deterministically route disjoint operations without eagerly starting children. */ export class CompositeProvider implements AnalysisProvider { readonly #identity: ProviderIdentity; @@ -85,14 +170,7 @@ export class CompositeProvider implements AnalysisProvider { clients.set(provider, created); return created; }; - const close: AnalysisClient["close"] = async (options) => { - const outcomes = await Promise.all( - [...clients.entries()].map(([provider, client]) => - closeAnalysisClient(client, provider.identity().id, options), - ), - ); - return outcomes.find((outcome) => !outcome.ok) ?? ok(null); - }; + const close = closeCompositeClients(this.#identity, clients); return { executeIfReady: (operation, parameters, options) => { const provider = this.#providerByOperation.get(operation); @@ -103,18 +181,9 @@ export class CompositeProvider implements AnalysisProvider { Promise.resolve(undefined) ); }, - execute: (operation, parameters, options) => { + execute: async (operation, parameters, options) => { if (operation === "health") - return Promise.resolve( - ok({ - result: null, - rawResult: null, - provider: this.#identity, - limitations: [], - locations: [], - subject: null, - }), - ); + return executeCompositeHealth(this.#identity, clients, options); const provider = this.#providerByOperation.get(operation); return provider === undefined ? Promise.resolve( @@ -141,16 +210,16 @@ export class CompositeProvider implements AnalysisProvider { compareUnicodeCodePoints(left.provider.id, right.provider.id), ), operationHealthSnapshot: () => { - for (const client of clients.values()) { - const snapshot = client.operationHealthSnapshot?.(); - if (snapshot !== undefined && snapshot.state !== "idle") - return snapshot; - } - for (const client of clients.values()) { - const snapshot = client.operationHealthSnapshot?.(); - if (snapshot !== undefined) return snapshot; - } - return undefined; + const snapshots = [...clients.values()].map((client) => + client.operationHealthSnapshot?.(), + ); + if (snapshots.some((snapshot) => snapshot === undefined)) + return undefined; + return ( + snapshots.find( + (snapshot) => snapshot !== undefined && snapshot.state !== "idle", + ) ?? snapshots[0] + ); }, close, }; From b940ad0d65a4db6de1d8416d2d538c191c692708 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:27 +0800 Subject: [PATCH 02/18] fix(sqlite): retain completed worker evidence and unresolved cleanup ownership --- src/process/OwnedCommand.ts | 13 +- src/sqlite/SqliteDatabaseProvider.ts | 123 +++++++++++++----- src/sqlite/SqliteDatabaseWorkerExecution.ts | 11 ++ .../sqlite/sqliteDatabaseProvider.test.ts | 85 ++++++++++++ 4 files changed, 198 insertions(+), 34 deletions(-) diff --git a/src/process/OwnedCommand.ts b/src/process/OwnedCommand.ts index 3c2e0912d..3a77c7342 100644 --- a/src/process/OwnedCommand.ts +++ b/src/process/OwnedCommand.ts @@ -1,6 +1,7 @@ import type { OwnedProviderProcessSpawnOptions, ProviderProcessSnapshot, + ProviderProcessStopResult, SpawnedOwnedProviderProcess, } from "./ProviderProcess.js"; import { @@ -63,7 +64,10 @@ export const runOwnedCommand = async ( options: { readonly signal?: AbortSignal | undefined; /** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */ - readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void; + readonly retainCleanup?: ( + owner: ProviderProcessSupervisor, + result: Extract, + ) => void; readonly launcher?: ( input: OwnedProviderProcessSpawnOptions, ) => Promise; @@ -102,7 +106,10 @@ const collectOwnedCommand = async ( ) => Promise, deadline: ProviderStartupDeadline, callerSignal?: AbortSignal, - retainCleanup?: (owner: ProviderProcessSupervisor) => void, + retainCleanup?: ( + owner: ProviderProcessSupervisor, + result: Extract, + ) => void, ): Promise => { const signal = deadline.signal; let launched: SpawnedOwnedProviderProcess; @@ -216,7 +223,7 @@ const collectOwnedCommand = async ( const stopped = await supervisor.stop(); const snapshot = supervisor.snapshot(); if (stopped.status === "incomplete" && retainCleanup !== undefined) - retainCleanup(supervisor); + retainCleanup(supervisor, stopped); else if (stopped.status === "incomplete") throw new OwnedCommandFailure( failure?.reason ?? "process", diff --git a/src/sqlite/SqliteDatabaseProvider.ts b/src/sqlite/SqliteDatabaseProvider.ts index c44185616..03929b2a7 100644 --- a/src/sqlite/SqliteDatabaseProvider.ts +++ b/src/sqlite/SqliteDatabaseProvider.ts @@ -17,6 +17,7 @@ import { AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { createEvidence } from "../domain/evidence.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -29,8 +30,14 @@ import { } from "../domain/sqlite/sqliteDatabase.js"; import { isAbsolute } from "node:path"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -import { OwnedCommandFailure } from "../process/OwnedCommand.js"; -import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; +import { + OwnedCommandFailure, + ownedCommandResources, +} from "../process/OwnedCommand.js"; +import type { + ProviderProcessStopResult, + ProviderProcessSupervisor, +} from "../process/ProviderProcess.js"; import { sqliteDatabaseFailure } from "./SqliteDatabaseFailures.js"; import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js"; import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; @@ -228,6 +235,9 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { ): Promise { let root: Pick | undefined; let processOwner: ProviderProcessSupervisor | undefined; + let processCleanup: + | Extract + | undefined; let retainedOutput: AnalysisCapturedOutput | undefined; let phase = "configuration"; let result: Result; @@ -258,6 +268,10 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { captured: (output) => { retainedOutput = output; }, + retainCleanup: (owner, result) => { + processOwner = owner; + processCleanup = result; + }, }); const validated = sqliteDatabaseSchema.safeParse({ artifact: snapshot.artifact, @@ -293,6 +307,39 @@ export class SqliteDatabaseProvider implements SqliteDatabasePort { output: retainedOutput, process: processOwner, }); + if (processCleanup !== undefined) { + const ownership = processOwner.launch.ownership; + return err( + sqliteCleanupFailure( + ownership === undefined + ? ["owned-sqlite-worker", root.path] + : ownedCommandResources(ownership, root.path), + processCleanup.reason, + result, + input, + retainedOutput, + processCleanup.cleanupResult === undefined + ? undefined + : { + ...(processCleanup.cleanupResult.failures === undefined + ? {} + : { + failures: processCleanup.cleanupResult.failures.map( + (failure) => ({ ...failure }), + ), + }), + ...(processCleanup.cleanupResult.unverified === undefined + ? {} + : { + unverified: + processCleanup.cleanupResult.unverified.map( + (process) => ({ ...process }), + ), + }), + }, + ), + ); + } return result; } const cleaned = await cleanupSqliteRoot( @@ -360,41 +407,55 @@ const cleanupSqliteRoot = async ( return ok(null); } catch (cause: unknown) { return err( - new ProviderCleanupError( - SQLITE_PROVIDER_IDENTITY.id, + sqliteCleanupFailure( [root.path], - { - reason: cause instanceof Error ? cause.message : String(cause), - previous_error: result.ok ? null : projectAnalysisError(result.error), - ...(output === undefined ? {} : { captured_output: { ...output } }), - }, - { - operation, - cause, - ...(result.ok - ? { - partialObservation: createEvidence( - result.value.subject ?? undefined, - result.value.provider, - { - operation, - parameters: sqliteInspectionParameters(input), - result: result.value.result, - confidence: "observed", - locations: result.value.locations, - limitations: result.value.limitations, - }, - ), - } - : result.error.partialObservation === undefined - ? {} - : { partialObservation: result.error.partialObservation }), - }, + cause instanceof Error ? cause.message : String(cause), + result, + input, + output, + undefined, + cause, ), ); } }; +const sqliteCleanupFailure = ( + resources: readonly string[], + reason: string, + result: SqliteOutcome, + input: InspectSqliteDatabaseInput, + output?: AnalysisCapturedOutput, + details?: Readonly>, + cause?: unknown, +): ProviderCleanupError => { + const partialObservation = result.ok + ? createEvidence(result.value.subject ?? undefined, result.value.provider, { + operation, + parameters: sqliteInspectionParameters(input), + result: result.value.result, + confidence: "observed", + locations: result.value.locations, + limitations: result.value.limitations, + }) + : result.error.partialObservation; + return new ProviderCleanupError( + SQLITE_PROVIDER_IDENTITY.id, + resources, + { + ...details, + reason, + previous_error: result.ok ? null : projectAnalysisError(result.error), + ...(output === undefined ? {} : { captured_output: { ...output } }), + }, + { + operation, + ...(cause === undefined ? {} : { cause }), + ...(partialObservation === undefined ? {} : { partialObservation }), + }, + ); +}; + const validateSqliteInput = (input: InspectSqliteDatabaseInput): void => { const parsed = inspectSqliteDatabaseInputSchema.safeParse(input); if (!parsed.success || !isAbsolute(input.path)) diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index cba180399..930a871a4 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -19,6 +19,10 @@ import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { runOwnedCommand } from "../process/OwnedCommand.js"; +import type { + ProviderProcessSupervisor, + ProviderProcessStopResult, +} from "../process/ProviderProcess.js"; import { capturedSqliteOutput } from "./SqliteDatabaseFailures.js"; import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; @@ -109,6 +113,10 @@ export const executeSqliteDatabaseWorker = async (request: { readonly launcher?: SqliteWorkerLauncher; readonly signal?: AbortSignal; readonly captured: (output: AnalysisCapturedOutput) => void; + readonly retainCleanup?: ( + owner: ProviderProcessSupervisor, + result: Extract, + ) => void; }): Promise> => { const requestPath = join(request.root, "request.json"); const replyPath = join(request.root, "reply.json"); @@ -143,6 +151,9 @@ export const executeSqliteDatabaseWorker = async (request: { { ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.launcher === undefined ? {} : { launcher: request.launcher }), + ...(request.retainCleanup === undefined + ? {} + : { retainCleanup: request.retainCleanup }), }, ); const capturedOutput = capturedSqliteOutput(execution); diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index 5ecc35672..20f882726 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { expect, it as nativeTest, onTestFinished } from "vitest"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js"; +import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; @@ -49,6 +50,90 @@ const createFixture = async (wal = false) => { const expectPrivateRootRemoved = (path: string) => expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); +it.each(["completed reply", "malformed reply"] as const)( + "preserves the primary result for a %s while retaining unconfirmed cleanup ownership", + async (replyKind) => { + const { workspace, database } = await createFixture(); + let cleanupAttempts = 0; + let ownedRoot = ""; + const provider = new SqliteDatabaseProvider(process.env, async (spawn) => { + ownedRoot = spawn.cwd ?? ""; + const requestPath = spawn.arguments[2]; + if (requestPath === undefined) + throw new Error("SQLite request path missing"); + const request = JSON.parse(await readFile(requestPath, "utf8")) as { + snapshot_path: string; + reply_path: string; + input: Parameters[1]; + }; + const inspection = + replyKind === "completed reply" + ? inspectSqliteDatabaseSnapshot(request.snapshot_path, request.input) + : undefined; + await writeFile( + request.reply_path, + inspection === undefined + ? "{ malformed reply" + : JSON.stringify({ ok: true, inspection }), + { flag: "wx", mode: 0o600 }, + ); + const launched = await spawnOwnedProviderProcess({ + ...spawn, + arguments: ["-e", "process.exit(0)"], + }); + return { + ...launched, + cleanup: async () => { + cleanupAttempts += 1; + return cleanupAttempts === 1 + ? { cleaned: false, reason: "fixture cleanup denial" } + : { cleaned: true, signaled: false }; + }, + }; + }); + onTestFinished(async () => { + await provider.close(); + await removeTestWorkspace(workspace.root); + }); + + const result = await provider.inspect({ + path: database.path, + table: "records", + columns: ["id"], + row_limit: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: expect.arrayContaining([ownedRoot]), + diagnostics: { + reason: "fixture cleanup denial", + previous_error: + replyKind === "malformed reply" ? expect.any(Object) : null, + }, + ...(replyKind === "completed reply" + ? { + partialObservation: { + operation: "inspect_sqlite_database", + confidence: "observed", + normalized_result: { + schema: { tables: expect.any(Array) }, + rows: { returned_rows: 1 }, + }, + }, + } + : { partialObservation: undefined }), + }, + }); + expect(cleanupAttempts).toBe(1); + await expect(access(`${ownedRoot}/reply.json`)).resolves.toBeUndefined(); + await provider.close(); + expect(cleanupAttempts).toBe(2); + await expectPrivateRootRemoved(ownedRoot); + }, +); + it.each(["late-cancellation", "cleanup-failure"])( "preserves completed native-worker observations across %s", async (scenario) => { From 5fa7a52d1f4b28c0b842c5737b84fef6ef2a8eb4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:28 +0800 Subject: [PATCH 03/18] fix(frida): preserve script diagnostics and isolate capture truncation --- .../frida/FridaInstrumentationManager.test.ts | 163 +++++++++++++++++- src/frida/FridaInstrumentationManager.ts | 93 ++++++++-- src/frida/frida-bindings.d.ts | 3 + 3 files changed, 238 insertions(+), 21 deletions(-) diff --git a/src/application/frida/FridaInstrumentationManager.test.ts b/src/application/frida/FridaInstrumentationManager.test.ts index d66280f66..11f9ca81e 100644 --- a/src/application/frida/FridaInstrumentationManager.test.ts +++ b/src/application/frida/FridaInstrumentationManager.test.ts @@ -9,6 +9,7 @@ import type { Session, Signal, } from "frida"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { FridaInstrumentationManager } from "../../frida/FridaInstrumentationManager.js"; const signal = void>() => { @@ -40,13 +41,14 @@ class FakeScript implements Script { readonly beforeLoad: () => Promise = async () => {}, readonly description = "script loaded", readonly stack = "", + readonly messageValue?: FridaMessage, ) {} async load(): Promise { await this.beforeLoad(); if (this.loadFailure !== undefined) throw this.loadFailure; this.message.emit( - { + this.messageValue ?? { type: "send", payload: this.payload, description: this.description, @@ -73,6 +75,7 @@ class FakeSession implements Session { nextPayload: unknown = { event: "loaded" }; nextDescription = "script loaded"; nextStack = ""; + nextMessage: FridaMessage | undefined; nextLoadFailure: Error | undefined; nextUnloadFailureCount = 0; detachFailureCount = 0; @@ -94,12 +97,14 @@ class FakeSession implements Session { }, this.nextDescription, this.nextStack, + this.nextMessage, ); script.loadFailure = this.nextLoadFailure; script.unloadFailureCount = this.nextUnloadFailureCount; this.nextPayload = { event: "loaded" }; this.nextDescription = "script loaded"; this.nextStack = ""; + this.nextMessage = undefined; this.nextLoadFailure = undefined; this.nextUnloadFailureCount = 0; this.scripts.push(script); @@ -744,7 +749,93 @@ it("attributes messages to their producing script and returns each load's messag await manager.closeAll(); }); -it("keeps an active script message bucket after its last message is evicted", async () => { +it("retains Frida error source coordinates in script and session evidence", async () => { + const { bindings, manager, started } = await startLocalAttachedSession(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + bindings.device.session.nextMessage = { + type: "error", + description: "instrumentation failed", + stack: "Error: instrumentation failed", + fileName: "agent.js", + lineNumber: 7, + columnNumber: 3, + }; + const loaded = await manager.loadScript(started.value.sessionId, { + sourceKind: "inline", + source: "throw new Error('instrumentation failed');", + }); + expect(loaded.ok).toBe(true); + if (loaded.ok) { + expect(loaded.value.messages[0]).toMatchObject({ + type: "error", + file_name: "agent.js", + line_number: 7, + column_number: 3, + }); + } + expect(manager.status(started.value.sessionId)?.messages[0]).toMatchObject({ + file_name: "agent.js", + line_number: 7, + column_number: 3, + }); + + bindings.device.session.nextMessage = { + type: "error", + description: "location unavailable", + stack: "Error: location unavailable", + }; + const withoutLocation = await manager.loadScript(started.value.sessionId, { + sourceKind: "inline", + source: "throw new Error('location unavailable');", + }); + expect(withoutLocation.ok).toBe(true); + if (withoutLocation.ok) { + expect(withoutLocation.value.messages[0]).not.toHaveProperty("file_name"); + expect(withoutLocation.value.messages[0]).not.toHaveProperty("line_number"); + expect(withoutLocation.value.messages[0]).not.toHaveProperty( + "column_number", + ); + } + await manager.closeAll(); +}); + +it("does not carry earlier script truncation into a complete later load", async () => { + const { bindings, manager, started } = await startLocalAttachedSession(); + expect(started.ok).toBe(true); + if (!started.ok) return; + + const first = await manager.loadScript(started.value.sessionId, { + sourceKind: "inline", + source: "send('A');", + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const firstScript = bindings.device.session.scripts[0]; + expect(firstScript).toBeDefined(); + if (firstScript === undefined) return; + for (let index = 0; index < 800; index += 1) + firstScript.message.emit( + { type: "send", payload: "x".repeat(32_700) }, + null, + ); + + bindings.device.session.nextPayload = { tag: "B" }; + const second = await manager.loadScript(started.value.sessionId, { + sourceKind: "inline", + source: "send('B');", + }); + expect(second.ok).toBe(true); + if (second.ok) { + expect(second.value.messages).toHaveLength(1); + expect(second.value.messagesTruncated).toBe(false); + } + expect(manager.status(started.value.sessionId)?.messagesTruncated).toBe(true); + await manager.closeAll(); +}); + +it("reports truncation when that script loses its own message", async () => { const { bindings, manager, started } = await startLocalAttachedSession(); const session = bindings.device.session; expect(started.ok).toBe(true); @@ -800,6 +891,7 @@ it("keeps an active script message bucket after its last message is evicted", as ); expect(second.value.messagesTruncated).toBe(true); } + expect(manager.status(started.value.sessionId)?.messagesTruncated).toBe(true); expect(manager.status(started.value.sessionId)?.messages).toContainEqual( expect.objectContaining({ script_id: second.ok ? second.value.scriptId : "", @@ -926,6 +1018,73 @@ it("redacts remote credentials and maps a missing optional binding", async () => expect(unavailable.error.message).toContain("Cannot find package 'frida'"); }); +it("clears current binding failure after recovery", async () => { + const bindings = new FakeDeviceManager(); + let attempts = 0; + const manager = new FridaInstrumentationManager({ + deviceManagerLoader: async () => { + attempts += 1; + if (attempts === 1) throw new Error("transient binding load failure"); + return bindings; + }, + }); + + const failed = await manager.listDevices(); + expect(failed.ok).toBe(false); + if (!failed.ok) + expect(failed.error.message).toContain("transient binding load failure"); + expect(manager.availabilityFailure()?.reason).toBe( + "transient binding load failure", + ); + + const recovered = await manager.listDevices(); + expect(recovered.ok).toBe(true); + expect(manager.availabilityFailure()).toBeUndefined(); +}); + +it("keeps remote option conflicts as actionable invalid input", async () => { + const manager = new FridaInstrumentationManager({ + deviceManager: new FakeDeviceManager(), + }); + const retained = await manager.startSession({ + mode: "attach", + remote: { address: "127.0.0.1:27042", token: "first-token" }, + pid: 512, + }); + expect(retained.ok).toBe(true); + if (!retained.ok) return; + + const conflicting = [ + await manager.listDevices({ + address: "127.0.0.1:27042", + token: "other-token", + }), + await manager.listProcesses({ + remote: { address: "127.0.0.1:27042", token: "other-token" }, + }), + await manager.startSession({ + mode: "attach", + remote: { address: "127.0.0.1:27042", token: "other-token" }, + pid: 512, + }), + ]; + for (const result of conflicting) { + expect(result.ok).toBe(false); + if (result.ok) continue; + if (!(result.error instanceof AnalysisInputError)) throw result.error; + expect(result.error.operation).not.toBe("frida_remote_connection"); + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: ["remote"], + reason: "invalid_value", + message: expect.stringContaining("different connection options"), + }), + ); + expect(result.error.message).not.toContain("other-token"); + } + await manager.closeAll(); +}); + it("redacts remote credentials echoed by a target script", async () => { const token = "synthetic-token-from-auth"; const certificate = "synthetic-certificate-from-auth"; diff --git a/src/frida/FridaInstrumentationManager.ts b/src/frida/FridaInstrumentationManager.ts index b93cffae5..ea77f6e81 100644 --- a/src/frida/FridaInstrumentationManager.ts +++ b/src/frida/FridaInstrumentationManager.ts @@ -92,6 +92,7 @@ interface ManagedSession extends ManagedRemoteConnection { resumeAfterDetachPending: boolean; capturedBytes: number; messagesTruncated: boolean; + readonly truncatedScripts: Set; } export interface FridaInstrumentationManagerOptions { @@ -115,6 +116,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { readonly #remoteQueues = new Map>(); readonly #deviceManagerLoader: () => Promise; #deviceManager: DeviceManager | undefined; + #deviceManagerLoading: Promise | undefined; #availabilityFailure: OptionalProviderLoadFailure | undefined; readonly #operations = new Set>(); #closing = false; @@ -307,6 +309,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { resumeAfterDetachPending: false, capturedBytes: 0, messagesTruncated: false, + truncatedScripts: new Set(), }; session.detached.connect(() => { managed.state = "detached"; @@ -432,7 +435,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { sourcePath: source.sourceKind === "file" ? source.path : null, sourceSha256, messages: managed.messagesByScript.get(scriptId)?.toArray() ?? [], - messagesTruncated: managed.messagesTruncated, + messagesTruncated: managed.truncatedScripts.has(scriptId), }; if (signal?.aborted) { const cleanupReason = await this.#releaseScript(managed, entry); @@ -598,6 +601,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } managed.scripts.delete(entry.id); managed.messagesByScript.delete(entry.id); + managed.truncatedScripts.delete(entry.id); return undefined; } @@ -916,7 +920,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { bytes: Buffer.byteLength('{"payload_unavailable":true}'), }; } - if (payloadResult.truncated) managed.messagesTruncated = true; + if (payloadResult.truncated) markMessageTruncated(managed, scriptId); } let binaryResult: Record = {}; if (data !== null) { @@ -939,7 +943,7 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { data_truncated: true, data_bytes: data.byteLength, }; - managed.messagesTruncated = true; + markMessageTruncated(managed, scriptId); } else { binaryResult = { data_base64: data.toString("base64") }; } @@ -961,12 +965,21 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { message.stack === undefined ? undefined : boundedRedactedString(message.stack, MAX_MESSAGE_TEXT_BYTES, secrets); + const fileName = + message.fileName === undefined + ? undefined + : boundedRedactedString( + message.fileName, + MAX_MESSAGE_TEXT_BYTES, + secrets, + ); if ( type.truncated || description?.truncated === true || - stack?.truncated === true + stack?.truncated === true || + fileName?.truncated === true ) - managed.messagesTruncated = true; + markMessageTruncated(managed, scriptId); const observation: JsonValue = { script_id: scriptId, type: type.value, @@ -989,11 +1002,27 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { stack: stack.value, ...(stack.truncated ? { stack_truncated: true } : {}), }), + ...(fileName === undefined + ? {} + : { + file_name: fileName.value, + ...(fileName.truncated ? { file_name_truncated: true } : {}), + }), + ...(typeof message.lineNumber === "number" && + Number.isSafeInteger(message.lineNumber) && + message.lineNumber >= 0 + ? { line_number: message.lineNumber } + : {}), + ...(typeof message.columnNumber === "number" && + Number.isSafeInteger(message.columnNumber) && + message.columnNumber >= 0 + ? { column_number: message.columnNumber } + : {}), ...binaryResult, }; const charge = Buffer.byteLength(JSON.stringify(observation)); if (charge > MAX_SESSION_CAPTURE_BYTES) { - managed.messagesTruncated = true; + markMessageTruncated(managed, scriptId); return; } while ( @@ -1008,8 +1037,8 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { const scriptMessages = managed.messagesByScript.get(removed.scriptId); if (scriptMessages?.peek() === removed.observation) scriptMessages.shift(); + markMessageTruncated(managed, removed.scriptId); } - managed.messagesTruncated = true; } managed.messages.push({ observation, scriptId, charge }); managed.messagesByScript.get(scriptId)?.push(observation); @@ -1017,18 +1046,26 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { } async #getDeviceManager(): Promise { - if (this.#deviceManager === undefined) { - try { - this.#deviceManager = await this.#deviceManagerLoader(); - } catch (cause: unknown) { - this.#availabilityFailure = { - providerId: "frida", - reason: cause instanceof Error ? cause.message : String(cause), - }; - throw cause; - } + if (this.#deviceManager !== undefined) return this.#deviceManager; + if (this.#deviceManagerLoading !== undefined) + return this.#deviceManagerLoading; + const loading = Promise.resolve().then(this.#deviceManagerLoader); + this.#deviceManagerLoading = loading; + try { + this.#deviceManager = await loading; + if (this.#availabilityFailure?.providerId === "frida") + this.#availabilityFailure = undefined; + return this.#deviceManager; + } catch (cause: unknown) { + this.#availabilityFailure = { + providerId: "frida", + reason: boundedRedactedString(errorMessage(cause), 4096, []).value, + }; + throw cause; + } finally { + if (this.#deviceManagerLoading === loading) + this.#deviceManagerLoading = undefined; } - return this.#deviceManager; } async #selectDevice( @@ -1054,7 +1091,14 @@ export class FridaInstrumentationManager implements FridaInstrumentationPort { let reference = this.#remoteDevices.get(remote.address); if (reference !== undefined) { if (reference.optionsDigest !== optionsDigest) - throw new AnalysisInputError("frida_remote_connection"); + throw new AnalysisInputError("frida_remote_connection", undefined, [ + { + path: ["remote"], + reason: "invalid_value", + message: + "This endpoint is already retained with different connection options. Reuse the existing options or close its sessions before changing them.", + }, + ]); reference.references += 1; } else { const device = manager.addRemoteDevice( @@ -1361,6 +1405,8 @@ const providerError = ( remote?: FridaRemoteConnection, cleanupReason?: string, ): AnalysisError => { + if (cause instanceof AnalysisInputError) + return new AnalysisInputError(operation, { cause }, cause.issues); if (cause instanceof AnalysisCancelledError) return new AnalysisCancelledError(operation, { cause: cause.cause, @@ -1403,6 +1449,15 @@ interface CapturedMessage { readonly charge: number; } +const markMessageTruncated = ( + managed: ManagedSession, + scriptId: string, +): void => { + managed.messagesTruncated = true; + if (managed.messagesByScript.has(scriptId)) + managed.truncatedScripts.add(scriptId); +}; + /** * Arrival-ordered queue whose eviction advances a head offset. A full capture * budget evicts on every message, and `Array#shift` would copy every retained diff --git a/src/frida/frida-bindings.d.ts b/src/frida/frida-bindings.d.ts index 9ccdb8953..280e8c270 100644 --- a/src/frida/frida-bindings.d.ts +++ b/src/frida/frida-bindings.d.ts @@ -4,6 +4,9 @@ declare module "frida" { readonly payload?: unknown; readonly description?: string; readonly stack?: string; + readonly fileName?: string; + readonly lineNumber?: number; + readonly columnNumber?: number; } export type SignalHandler = (...args: never[]) => void; From 36db19c59c9e7bd36b00a468b589d1cb5c3d2d49 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:28 +0800 Subject: [PATCH 04/18] fix(providers)!: preserve unknown segment extents and initialized IDA readiness BREAKING CHANGE: binary_overview segment length is null when Hopper coordinates do not establish a finite same-address-space extent. Consumers must preserve this unknown instead of treating it as zero or a saturated value. --- src/contracts/toolOutputSchemaGroups.ts | 2 +- src/domain/hopperValues.test.ts | 10 ++++++ src/domain/hopperValues.ts | 13 +++---- src/ida/IdaSessionClient.test.ts | 25 ++++++++++++- src/ida/IdaSessionClient.ts | 7 ++-- ...4-uses-complete-inventories-inline.test.ts | 36 ++++++++++++++++++- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index bbadef367..d68512d49 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -367,7 +367,7 @@ export const enhancedOutputSchemas: Readonly> = { name: z.string(), start: z.string(), end: z.string(), - length: z.number().min(0), + length: z.number().min(0).nullable(), image_base: z.string().exactOptional(), address_space: z.string().exactOptional(), }), diff --git a/src/domain/hopperValues.test.ts b/src/domain/hopperValues.test.ts index 7764e330e..493d58122 100644 --- a/src/domain/hopperValues.test.ts +++ b/src/domain/hopperValues.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { functionBodySchema, + addressDistance, procedureIdentitySchema, parseDocuments, parseListCount, @@ -12,6 +13,15 @@ import { } from "./hopperValues.js"; describe("Hopper boundary values", () => { + it("distinguishes measured zero, malformed, incomparable, reversed, and oversized extents", () => { + expect(addressDistance("not-an-address", "0x2000")).toBeNull(); + expect(addressDistance("ram:0x1000", "rom:0x2000")).toBeNull(); + expect(addressDistance("0x2000", "0x1000")).toBeNull(); + expect(addressDistance("0x1000", "0x1000")).toBe(0); + expect(addressDistance("0x1000", "0x2000")).toBe(4096); + expect(addressDistance("0x0", "0x20000000000000")).toBeNull(); + }); + it("parses Hopper's address-keyed name and string maps", () => { expect(parseNames({ "0x1000": "_main" })).toEqual({ ok: true, diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 6596d0d7b..e346ac59f 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -25,17 +25,14 @@ export interface SegmentSummary { } /** Return a bounded byte distance between canonical addresses in one space. */ -export const addressDistance = (start: string, end: string): number => { +export const addressDistance = (start: string, end: string): number | null => { const left = addressCoordinate(start); const right = addressCoordinate(end); - if (left === null || right === null || left.space !== right.space) return 0; + if (left === null || right === null || left.space !== right.space) + return null; const distance = right.offset - left.offset; - if (distance <= 0n) return 0; - return Number( - distance > BigInt(Number.MAX_SAFE_INTEGER) - ? BigInt(Number.MAX_SAFE_INTEGER) - : distance, - ); + if (distance < 0n || distance > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(distance); }; const procedureMapSchema = z.record(z.string(), z.string()); diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 3d0feeed2..c6bffd2e0 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -153,7 +153,28 @@ describe("headless IDA producer pagination", () => { }); }); -describe("attached IDA observation semantics", () => { +describe("attached IDA readiness and queued cancellation", () => { + it("allows ready-only work after an attached provider has initialized", async () => { + const { producer, client } = await fixture("attached"); + await expect(client.executeIfReady("list_procedures", {})).resolves.toBe( + undefined, + ); + + const startup = await client.execute("list_procedures", {}); + if (!startup.ok) throw startup.error; + const result = await client.executeIfReady("list_procedures", {}); + if (result === undefined) + throw new Error("expected ready attached IDA dispatch"); + if (!result.ok) throw result.error; + expect(result.value.result).toEqual([ + expect.objectContaining({ address: "0x1000", value: "main" }), + ]); + expect( + producer.calls.filter(({ name }) => name === "idb_open"), + ).toHaveLength(0); + await client.close(); + }); + it("returns cold and pending startup calls immediately, then cancels queued work before dispatch", async () => { const { producer, client } = await fixture("headless"); await expect(client.executeIfReady("list_procedures", {})).resolves.toBe( @@ -233,7 +254,9 @@ describe("attached IDA observation semantics", () => { ); await client.close(); }); +}); +describe("attached IDA input identity and validation", () => { it("accepts a normalized spelling of the bound document path", async () => { const { target, producer, client } = await fixture(); const equivalent = diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index d157b5209..3dac1112e 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -162,6 +162,7 @@ export class IdaSessionClient implements AnalysisClient { #databaseCloseCompleted = false; #openAttempted = false; #openCompleted = false; + #ready = false; #blockingSessions: readonly UpstreamSession[] | undefined; #openRejected = false; #callGraphAdvertised = false; @@ -197,11 +198,10 @@ export class IdaSessionClient implements AnalysisClient { parameters: Readonly>, options?: ExecutionOptions, ) { - if (!this.#openCompleted || this.#closed || this.#closing) + if (!this.#ready || this.#closed || this.#closing) return Promise.resolve(undefined); const call = this.#tail.then(() => { - if (!this.#openCompleted || this.#closed || this.#closing) - return undefined; + if (!this.#ready || this.#closed || this.#closing) return undefined; if (wasCancelled(options)) return err(new AnalysisCancelledError(operation)); return this.#execute(operation, parameters, options, true); @@ -601,6 +601,7 @@ export class IdaSessionClient implements AnalysisClient { ); this.#database = opened.session.session_id; } + this.#ready = true; } async #nativeMetadata(): Promise { diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index c2c0c52e9..ab4760ca3 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -135,7 +135,7 @@ const completeOverviewPort: AnalysisOperationPort = { }, }; -describe("enhanced MCP tools", () => { +describe("enhanced MCP binary overview", () => { it("returns the complete overview inline with exhaustive totals", async () => { const client = await connect(completeOverviewPort); const result = jsonResult( @@ -210,7 +210,9 @@ describe("enhanced MCP tools", () => { string_count: 0, }); }); +}); +describe("enhanced MCP inventory validation", () => { it("rejects paginated provider output", async () => { const client = await connect({ execute: () => @@ -262,6 +264,38 @@ describe("enhanced MCP tools", () => { text: JSON.stringify(parseMcpToolError(result)), }); }); + + it("preserves unknown length for malformed segment coordinates", async () => { + const client = await connect({ + execute: (name) => { + if (name === "list_segments") + return Promise.resolve( + ok([ + { + name: "__TEXT", + start: "unparsed-start", + end: "0x2000", + readable: null, + writable: null, + executable: null, + }, + ]), + ); + if (name === "current_document") return Promise.resolve(ok("fixture")); + if (name === "list_procedures" || name === "list_strings") + return Promise.resolve(ok([])); + throw new Error(`Unexpected analysis operation: ${name}`); + }, + }); + expect( + jsonResult( + await client.callTool({ name: "binary_overview", arguments: {} }), + ), + ).toMatchObject({ + segments: [{ name: "__TEXT", start: "unparsed-start", length: null }], + segment_count: 1, + }); + }); }); const overviewPort = ( From 9fe54c4073056587d4cdddd4ade06b993dc833d4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:29 +0800 Subject: [PATCH 05/18] fix(dotnet): honor bounded method row selections --- src/dotnet/ManagedMemberInspector.test.ts | 59 +++++++++++++++++++++++ src/dotnet/ManagedMemberInspector.ts | 16 ++++++ src/dotnet/ManagedMemberRows.ts | 17 +++++-- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 3aee06e73..ea4c41ac7 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -17,6 +17,7 @@ import { signature, type ManagedMethod } from "./ManagedMemberInspectorCore.js"; import { ManagedReaderFailure } from "./ManagedReaderFailure.js"; import { managedMemberInspectionSchema } from "../domain/managed/managedArtifact.js"; import { comparisonLimitations } from "../domain/managed/managedMemberComparisonCoverage.js"; +import { buildManagedPeFixture as buildMultiMethodManagedPeFixture } from "../../scripts/fixtures/managed/pe.mjs"; import { buildManagedPeFixture, @@ -1735,3 +1736,61 @@ describe("managed member type selection", () => { expect(result.member_refs).toEqual(everything.member_refs); }); }); + +describe("managed member method paging", () => { + it("returns only the requested MethodDef rows", () => { + const image = buildMultiMethodManagedPeFixture({ + methods: [ + { name: "First", body: Buffer.from([0x2a]) }, + { name: "Second", body: Buffer.from([0x2a]) }, + { name: "Third", body: Buffer.from([0x2a]) }, + ], + }); + const all = inspectManagedMembersBytes( + image, + managedPeFixtureTarget(image), + ); + expect(all.methods.map(({ name }) => name)).toEqual([ + "First", + "Second", + "Third", + ]); + + const page = inspectManagedMembersBytes( + image, + managedPeFixtureTarget(image), + { methodOffset: 1, methodLimit: 1 }, + ); + + expect(page.methods.map(({ name, token }) => ({ name, token }))).toEqual([ + { name: "Second", token: "0x06000002" }, + ]); + expect(page.coverage.state).toBe("complete"); + + const offsetOnly = inspectManagedMembersBytes( + image, + managedPeFixtureTarget(image), + { methodOffset: 1 }, + ); + expect(offsetOnly.methods.map(({ name }) => name)).toEqual([ + "Second", + "Third", + ]); + + const emptyPage = inspectManagedMembersBytes( + image, + managedPeFixtureTarget(image), + { methodOffset: 1, methodLimit: 0 }, + ); + expect(emptyPage.methods).toEqual([]); + }); + + it("rejects invalid method page bounds", () => { + const image = buildManagedPeFixture(); + expect(() => + inspectManagedMembersBytes(image, managedPeFixtureTarget(image), { + methodOffset: -1, + }), + ).toThrow(RangeError); + }); +}); diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 1b018522a..d6464b09d 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -160,12 +160,26 @@ export const inspectManagedMembersBytes = ( target: BinaryTarget, { typeNames, + methodOffset = 0, + methodLimit = Number.MAX_SAFE_INTEGER, userStrings: readUserStrings = true, }: { readonly typeNames?: readonly string[]; + /** Zero-based offset into MethodDef rows. */ + readonly methodOffset?: number; + readonly methodLimit?: number; readonly userStrings?: boolean; } = {}, ): ManagedMemberInspection => { + if ( + !Number.isSafeInteger(methodOffset) || + methodOffset < 0 || + !Number.isSafeInteger(methodLimit) || + methodLimit < 0 + ) + throw new RangeError( + "methodOffset and methodLimit must be non-negative safe integers", + ); const pe = readManagedPeLayout(bytes); if (pe.cli === null) return unavailable(target, bytes, pe.cliIssue, typeNames); @@ -196,6 +210,8 @@ export const inspectManagedMembersBytes = ( pe, ranges, selected, + methodOffset, + methodLimit, }); appendManagedParseIssues(issues, fields.issues, methods.issues); // #US literals sit beside the member inventory and carry their own diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 700f723e6..0f2bcc633 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -257,6 +257,8 @@ interface ParseMethodsInput { readonly pe: ManagedPeLayout; readonly ranges: readonly TypeRange[]; readonly selected?: SelectedTypeTokens; + readonly methodOffset?: number; + readonly methodLimit?: number; } export const parseMethods = ({ @@ -265,6 +267,8 @@ export const parseMethods = ({ pe, ranges, selected = null, + methodOffset = 0, + methodLimit = Number.MAX_SAFE_INTEGER, }: ParseMethodsInput): { readonly methods: readonly ManagedMethod[]; readonly core: ReadonlyMap; @@ -292,12 +296,14 @@ export const parseMethods = ({ ? undefined : readManagedValue(() => ownerRow(row), issues); const declared = owner === undefined ? undefined : ranges[owner - 1]; + const inPage = row > methodOffset && row - methodOffset <= methodLimit; const mayBeSelected = - selected === null || - owner === undefined || - isSelected(selected, declared) || - // A type whose name is unreadable may be one the caller requested. - declared?.fullName === null; + inPage && + (selected === null || + owner === undefined || + isSelected(selected, declared) || + // A type whose name is unreadable may be one the caller requested. + declared?.fullName === null); const produced = methods.length; readManagedValue(() => { const cursor = metadataRowCursor(bytes, layout, 6, row); @@ -318,6 +324,7 @@ export const parseMethods = ({ kind: "method", } as const; core.set(token, member); + if (!inPage) return; if (!isSelected(selected, declared)) return; const sig = readMetadataBlob( bytes, From 896696b2e761e34c267977bb5c6982c607a622d0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:29 +0800 Subject: [PATCH 06/18] fix(javascript): preserve semantic uncertainty and lexical ownership --- .../JavaScriptSemanticGraphBuilder.test.ts | 34 +++- .../electronStaticAnalysisOptions.test.ts | 30 ++++ .../javascriptApplicationChangeGraph.ts | 9 +- ...scriptApplicationVersionComparison.test.ts | 56 ++++++- .../javascriptSemanticAnalysis.calls.test.ts | 22 +++ ...tSemanticAnalysis.member_mutations.test.ts | 26 ++++ .../javascript/javascriptSemanticAnalysis.ts | 17 +- .../javascript/javascriptSemanticCalls.ts | 38 ++++- .../javascriptSemanticFingerprints.test.ts | 31 ++++ .../javascriptSemanticFingerprints.ts | 2 + .../javascript/javascriptSemanticGraph.ts | 6 +- .../javascriptSemanticGraphQuery.test.ts | 145 ++++++++++++++++++ .../javascriptSemanticObjectReferences.ts | 27 ++++ .../javascript/javascriptSemanticQuery.ts | 17 +- .../javascriptSymbolicBindings.test.ts | 56 +++++++ .../javascript/javascriptSymbolicBindings.ts | 29 ++++ 16 files changed, 525 insertions(+), 20 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticFingerprints.test.ts diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index c15437b08..d9bcd6ca7 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -65,16 +65,40 @@ it("projects closure and direct interprocedural flow without execution", () => { ); }); -it("projects a destructuring assignment member target as a property write", () => { - const graph = graphFor(` - const source = { count: 1 }; - [source.count] = [2]; - `); +it.each([ + ["direct assignment", "source.count = 2;"], + ["destructuring assignment", "[source.count] = [2];"], + ["for-of head", "for (source.count of [4]) {}"], + ["for-in head", "for (source.count in { key: true }) {}"], + ["delete", "delete source.count;"], +])("projects a %s member target as a property write", (_name, statement) => { + const graph = graphFor(`const source = { count: 1 }; ${statement}`); const operations = graph.nodes.filter( ({ properties }) => properties.operation_kind !== undefined, ); expect(operations.map(({ label }) => label)).toEqual(["write:/count"]); + const write = operations[0]; + if (write === undefined) throw new Error("Expected write operation node"); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: write.node_id, + relation: "writes-property", + }), + ); expect(graph.relations).not.toContainEqual( + expect.objectContaining({ + source_node_id: write.node_id, + relation: "reads-property", + }), + ); +}); + +it("continues to project an ordinary member access as a property read", () => { + const graph = graphFor(` + const source = { count: 1 }; + const value = source.count; + `); + expect(graph.relations).toContainEqual( expect.objectContaining({ relation: "reads-property" }), ); }); diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index 0fc044696..418920777 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -25,6 +25,36 @@ describe("Electron option evidence", () => { }); }); + it.each([ + ["for initializer", 'for (var electron = require("electron"); false;) {}'], + ["do body", 'do { var electron = require("electron"); } while (false);'], + ])( + "recovers Electron bindings initialized unconditionally in a %s", + (_name, declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration} new electron.BrowserWindow({webPreferences:{preload:"./preload.js"}});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + }, + ); + + it.each([ + 'for (; false;) { var electron = require("electron"); }', + 'while (false) { var electron = require("electron"); }', + 'if (flag) { for (var electron = require("electron"); false;) {} }', + ])( + "keeps conditionally skipped loop declarations unresolved: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration} new electron.BrowserWindow({});`, + ); + expect(analysis.electron.browser_windows).toEqual([]); + }, + ); + it("does not read an audio or video preload attribute as a preload script", () => { const analysis = analyzeJavaScriptStaticSource(` jsx("audio", { controls: true, preload: "metadata", src }); diff --git a/src/domain/javascript/javascriptApplicationChangeGraph.ts b/src/domain/javascript/javascriptApplicationChangeGraph.ts index 134fff520..393503844 100644 --- a/src/domain/javascript/javascriptApplicationChangeGraph.ts +++ b/src/domain/javascript/javascriptApplicationChangeGraph.ts @@ -180,7 +180,12 @@ const comparisonEvidence = ( executable_sha256: null, }, coverage: complete - ? { status: "complete", truncated: false, omitted_count: 0, limits: [] } + ? { + status: "complete", + truncated: false, + omitted_count: 0, + limits: sourceCoverageLimits(input), + } : { status: "partial", truncated: @@ -210,7 +215,7 @@ const changeGraphCoverage = ( status: "complete", truncated: false, omitted_count: 0, - limits: [], + limits: sourceCoverageLimits(input), }; return { status: "partial", diff --git a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts index d73dceaf6..fdb36b5dc 100644 --- a/src/domain/javascript/javascriptApplicationVersionComparison.test.ts +++ b/src/domain/javascript/javascriptApplicationVersionComparison.test.ts @@ -143,7 +143,7 @@ const nativeEvidence = (sha256: string | undefined, ordinal: number) => }, ); -describe("application version comparison result", () => { +describe("application version comparison Evidence links", () => { it("links every exact digest observation only to its paired or unmatched item", () => { const left = buildGraph(LEFT_SHA, 1); const right = buildGraph(RIGHT_SHA, 2); @@ -227,7 +227,9 @@ describe("application version comparison result", () => { ].sort(), ); }); +}); +describe("application version comparison graph ownership", () => { it("returns a sealed change graph that passes complete validation", () => { const result = compare(); expect( @@ -243,6 +245,58 @@ describe("application version comparison result", () => { ); }); + it("retains deduplicated complete input limits on the change graph and inferred edge", () => { + const leftLimits = [ + { name: "modules", value: 100, unit: "items" as const }, + ]; + const rightLimits = [ + { name: "source-bytes", value: 4096, unit: "bytes" as const }, + { name: "modules", value: 100, unit: "items" as const }, + ]; + const withLimits = ( + graph: JavaScriptApplicationGraph, + limits: JavaScriptApplicationGraph["coverage"]["limits"], + ): JavaScriptApplicationGraph => { + const { graph_id: _graphId, ...input } = graph; + return createJavaScriptApplicationGraph({ + ...input, + coverage: { ...graph.coverage, limits }, + }); + }; + const result = compareJavaScriptApplicationVersions({ + left: { + evidenceId: `ev_${"1".repeat(64)}`, + rootArtifactSha256: LEFT_SHA, + graph: withLimits(buildGraph(LEFT_SHA, 1), leftLimits), + }, + right: { + evidenceId: `ev_${"2".repeat(64)}`, + rootArtifactSha256: RIGHT_SHA, + graph: withLimits(buildGraph(RIGHT_SHA, 2), rightLimits), + }, + leftNativeEvidence: [], + rightNativeEvidence: [], + }); + const expectedLimits = [leftLimits[0]!, rightLimits[0]!]; + const changed = result.graph.edges.find( + ({ relation }) => relation === "changed_from", + ); + + expect(changed).toBeDefined(); + expect(result.graph.coverage).toMatchObject({ + status: "complete", + truncated: false, + omitted_count: 0, + limits: expectedLimits, + }); + expect(changed?.evidence.coverage).toMatchObject({ + status: "complete", + truncated: false, + omitted_count: 0, + limits: expectedLimits, + }); + }); + it("does not reuse a change graph without an owned proof", () => { const result = compare(); const copy = structuredClone(result.graph); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index f5ac65b7a..217480175 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -8,6 +8,28 @@ import { } from "./javascriptSemanticAnalysis.fixture.js"; describe("JavaScript semantic analysis: calls 1", () => { + it("does not assign computed method-name reads to method closures", () => { + const ir = analyzeJavaScriptSemantics(` + function outer() { + const name = "m"; + const object = { [name]() { return 1; }, ["literal"]() { return 2; } }; + class Example { + [name]() { return 3; } + ["literal"]() { return 4; } + } + } + `); + expect(ir.closureCaptures).toEqual([]); + + const bodyRead = analyzeJavaScriptSemantics(` + function outer() { + const value = 1; + const object = { method() { return value; } }; + } + `); + expect(bodyRead.closureCaptures).toHaveLength(1); + }); + it("links exact local calls to parameters, returns, and closure captures", () => { const ir = analyzeJavaScriptSemantics(` const prefix = "rea"; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index ee4e602b1..a9145a92c 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -164,6 +164,32 @@ describe("JavaScript semantic values after explicit property mutations", () => { }); }); +it("invalidates a shared value reached through an object-literal prototype getter", () => { + const value = resultValue(` + const shared = { x: 1 }; + const proto = { get v() { return shared; } }; + const box = { __proto__: proto }; + box.v.x = 2; + return shared.x; + `); + expect( + value?.status === "unknown" || + (value?.status === "literal" && value.value === 2), + ).toBe(true); +}); + +it("does not follow an object-literal prototype getter past an own property", () => { + expect( + resultValue(` + const shared = { x: 1 }; + const proto = { get v() { return shared; } }; + const box = { __proto__: proto, v: { x: 3 } }; + box.v.x = 2; + return shared.x; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + describe("JavaScript semantic mutations through getter results (#1494)", () => { it.each([ [ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index dcb913afd..e8bbffb48 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -860,13 +860,18 @@ const hasConditionalInitializer = (ancestors: readonly t.Node[]): boolean => { const node = ancestors[index]; if (t.isFunction(node) || t.isProgram(node) || t.isStaticBlock(node)) return false; - if ( - t.isIfStatement(node) || - t.isLoop(node) || - t.isSwitchCase(node) || - t.isTryStatement(node) - ) + if (t.isIfStatement(node) || t.isSwitchCase(node) || t.isTryStatement(node)) return true; + if (t.isLoop(node)) { + const bodyOrTest = ancestors[index + 1]; + if ( + (t.isForStatement(node) && bodyOrTest === node.init) || + (t.isDoWhileStatement(node) && + (bodyOrTest === node.body || bodyOrTest === node.test)) + ) + continue; + return true; + } } return false; }; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 53ada1654..b07715af8 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -58,6 +58,7 @@ interface CallCollectionContext { readonly JavaScriptSemanticBindingState[] >; readonly ancestors: t.Node[]; + readonly callableStack: JavaScriptSemanticCallable[]; } /** Recover direct-call and lexical-capture candidates from inert syntax. */ @@ -77,6 +78,7 @@ export const collectJavaScriptSemanticCalls = ( const callableById = new Map( callables.map((callable) => [callable.callableId, callable]), ); + const callableStack: JavaScriptSemanticCallable[] = []; const context: CallCollectionContext = { state, callableById, @@ -84,8 +86,8 @@ export const collectJavaScriptSemanticCalls = ( bindingResolutionCache: new Map(), parameterBindingCache: new Map(), ancestors: [], + callableStack, }; - const callableStack: JavaScriptSemanticCallable[] = []; traverseJavaScriptAst(program, { enter: (node, parent) => { const callableId = semanticCallableIdForNode(node); @@ -316,19 +318,49 @@ const collectCapture = ( ) return; if (semanticReferenceRole(node, parent) === null) return; + const captureOwner = computedMethodKeyOwner(node, owner, context); + if (captureOwner === undefined || captureOwner.bodyScopeId === null) return; const binding = resolveSemanticBindingState(state, node, node.name); if ( binding === undefined || - bindingIsWithinCallable(binding, owner.bodyScopeId, state) + bindingIsWithinCallable(binding, captureOwner.bodyScopeId, state) ) return; output.closureCaptures.push({ - callableId: owner.callableId, + callableId: captureOwner.callableId, bindingId: binding.bindingId, referenceLocation: range(node), }); }; +const computedMethodKeyOwner = ( + node: t.Node, + owner: JavaScriptSemanticCallable, + context: CallCollectionContext, +): JavaScriptSemanticCallable | undefined => { + const ownerNode = context.ancestors.findLast( + (candidate) => semanticCallableIdForNode(candidate) === owner.callableId, + ); + if ( + ownerNode === undefined || + (!t.isObjectMethod(ownerNode) && !t.isClassMethod(ownerNode)) || + !ownerNode.computed + ) + return owner; + let child = node; + let parent = context.state.parentsByNode.get(child); + while (parent !== undefined && parent !== ownerNode) { + child = parent; + parent = context.state.parentsByNode.get(parent); + } + if (parent !== ownerNode || ownerNode.key !== child) return owner; + return enclosingFunction( + context.callableStack.filter( + ({ callableId }) => callableId !== owner.callableId, + ), + ); +}; + const collectDynamicProperty = ( node: t.Node, owner: JavaScriptSemanticCallable | undefined, diff --git a/src/domain/javascript/javascriptSemanticFingerprints.test.ts b/src/domain/javascript/javascriptSemanticFingerprints.test.ts new file mode 100644 index 000000000..48a88d735 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticFingerprints.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const normalizedSyntaxFingerprint = (source: string): string => { + const [fingerprint] = analyzeJavaScriptSemantics(source).functionFingerprints; + expect(fingerprint?.status).toBe("complete"); + return fingerprint?.components.normalizedAstSha256 ?? ""; +}; + +it("distinguishes yielding an iterator result from delegating to the iterator", () => { + expect( + normalizedSyntaxFingerprint("function* sample(gen) { yield* gen(); }"), + ).not.toBe( + normalizedSyntaxFingerprint("function* sample(gen) { yield gen(); }"), + ); +}); + +it("distinguishes returned prefix and postfix updates", () => { + expect( + normalizedSyntaxFingerprint("function sample(i) { return ++i; }"), + ).not.toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }")); +}); + +it("keeps formatting and local renames normalized", () => { + expect( + normalizedSyntaxFingerprint( + "function renamed(value) {\n return value++ ;\n }", + ), + ).toBe(normalizedSyntaxFingerprint("function sample(i) { return i++; }")); +}); diff --git a/src/domain/javascript/javascriptSemanticFingerprints.ts b/src/domain/javascript/javascriptSemanticFingerprints.ts index c8615788c..6212ee5e4 100644 --- a/src/domain/javascript/javascriptSemanticFingerprints.ts +++ b/src/domain/javascript/javascriptSemanticFingerprints.ts @@ -198,6 +198,8 @@ const normalizedNodeToken = (node: t.Node, parent: t.Node | null): string => { primitiveAttribute(node, "optional"), primitiveAttribute(node, "async"), primitiveAttribute(node, "generator"), + primitiveAttribute(node, "delegate"), + primitiveAttribute(node, "prefix"), ].filter((value): value is string => value !== null); return [node.type, ...attributes].join(":"); }; diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index cdaa8c28b..4b77391f9 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -306,9 +306,11 @@ function* checkCoverageSteps( relationsByFamily.set(family, (relationsByFamily.get(family) ?? 0) + 1); } const unknownsById = new Map(); + const unknownFamilies = new Set(); for (const [index, unknown] of graph.unknowns.entries()) { if (index % 256 === 0) yield; unknownsById.set(unknown.unknown_id, unknown); + unknownFamilies.add(unknown.family); } for (const [index, family] of graph.coverage.families.entries()) { if (family.retained_relations !== relationsByFamily.get(family.family)) @@ -329,7 +331,9 @@ function* checkCoverageSteps( }); if ( family.status === "complete" && - (family.omitted_relations !== 0 || family.unknown_ids.length > 0) + (family.omitted_relations !== 0 || + family.unknown_ids.length > 0 || + unknownFamilies.has(family.family)) ) context.addIssue({ code: "custom", diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index 8b3e0e111..b24e105f3 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -2,6 +2,7 @@ import { semanticFixtureGraph, semanticFixtureInput, semanticGraphTestEvidence, + unknownSemanticGraphTestEvidence, } from "../../../tests/support/javascriptSemanticGraphFixture.js"; import { expect, it } from "vitest"; import { z } from "zod"; @@ -10,6 +11,7 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; import type { JsonValue } from "../jsonValue.js"; import type { ApplicationGraphEvidence } from "./javascriptApplicationEvidenceSchemas.js"; import { + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES, JAVASCRIPT_SEMANTIC_RELATION_FAMILY, JAVASCRIPT_SEMANTIC_NODE_KINDS, JAVASCRIPT_SEMANTIC_RELATIONS, @@ -244,6 +246,86 @@ it("matches scalar literal seeds and skips non-scalar literal metadata", () => { expect(query("unmatched")).toEqual([]); }); +it("scopes callers unknowns to call relations while preserving graph uncertainty", () => { + const contexts = new JavaScriptSemanticEvidenceContextRegistry(); + const caller = node("function", "caller", {}, contexts); + const seed = node("function", "seed", {}, contexts); + const call = createJavaScriptSemanticGraphRelation( + { + source_node_id: caller.node_id, + target_node_id: seed.node_id, + relation: "calls", + resolution: "resolved", + properties: {}, + evidence: semanticGraphTestEvidence("inferred"), + }, + contexts, + ); + const readUnknown = createJavaScriptSemanticGraphUnknown( + { + node_id: caller.node_id, + family: "data-flow", + relation_kinds: ["reads"], + reason: "dynamic-property", + detail: "Unresolved property read at the caller.", + candidate_node_ids: [], + evidence: unknownSemanticGraphTestEvidence(), + }, + contexts, + ); + const graph = createJavaScriptSemanticGraph({ + schema: "JavaScriptSemanticRelationGraph", + root_artifact_sha256: SHA, + application_graph_id: `jag_${"b".repeat(64)}`, + root_node_ids: [seed.node_id], + evidence_contexts: contexts.contexts, + nodes: [caller, seed], + relations: [call], + fingerprints: [], + unknowns: [readUnknown], + coverage: { + status: "partial", + truncated: false, + omitted_nodes: null, + omitted_relations: null, + limits: [], + families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({ + family, + status: + family === "data-flow" ? ("unknown" as const) : ("complete" as const), + retained_relations: + JAVASCRIPT_SEMANTIC_RELATION_FAMILY[call.relation] === family ? 1 : 0, + omitted_relations: family === "data-flow" ? null : 0, + unknown_ids: family === "data-flow" ? [readUnknown.unknown_id] : [], + })), + }, + limitations: ["One property read remains unresolved."], + }); + const query = ( + direction: "callers" | "backward-provenance", + allowed_relations?: ["calls"], + ) => + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: seed.node_id }, + direction, + ...(allowed_relations === undefined ? {} : { allowed_relations }), + }); + const callers = query("callers"); + const filtered = query("callers", ["calls"]); + const backward = query("backward-provenance"); + expect(callers.summary.traversed_nodes).toBe(2); + expect(callers.unknowns).toEqual([]); + expect(callers.summary.relevant_unknowns).toBe(0); + expect(callers.limitations).not.toContain( + "Relevant dynamic, unsupported, incomplete, or ambiguous semantics remain unknown.", + ); + expect(filtered.unknowns).toEqual(callers.unknowns); + expect(backward.unknowns.map(({ unknown_id }) => unknown_id)).toEqual([ + readUnknown.unknown_id, + ]); + expect(callers.coverage.status).toBe("partial"); +}); + it("counts candidate ownership cycles once and traverses them finitely", () => { const graph = graphWithCandidateRelations(); const binding = graph.nodes.find(({ label }) => label === "binding"); @@ -336,6 +418,69 @@ it("rejects stale identities, dangling endpoints, and incomplete coverage claims ); }); +it("rejects complete family coverage with an unlisted retained frontier", () => { + const input = semanticFixtureInput(true); + input.coverage = { + ...input.coverage, + status: "complete", + truncated: false, + omitted_nodes: 0, + omitted_relations: 0, + families: input.coverage.families.map((family) => + family.family === "call-flow" + ? { + ...family, + status: "complete", + omitted_relations: 0, + unknown_ids: [], + } + : family, + ), + }; + input.limitations = []; + expect(() => createJavaScriptSemanticGraph(input)).toThrow( + /Complete family coverage cannot omit or retain unknown facts/u, + ); + + const valid = createJavaScriptSemanticGraph(semanticFixtureInput(true)); + const coverage = { + ...valid.coverage, + status: "complete" as const, + truncated: false, + omitted_nodes: 0, + omitted_relations: 0, + families: valid.coverage.families.map((family) => + family.family === "call-flow" + ? { + ...family, + status: "complete" as const, + omitted_relations: 0, + unknown_ids: [], + } + : family, + ), + }; + const { graph_id: _previousId, ...semantic } = valid; + const changed = { ...semantic, coverage, limitations: [] }; + const invalid = { + ...changed, + graph_id: `jsrg_${digestCanonicalValue( + changed, + "JavaScript semantic graph", + )}`, + }; + const parsed = javaScriptSemanticGraphSchema.safeParse(invalid); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("Expected inconsistent graph rejection"); + expect(parsed.error.issues.map(({ message }) => message)).toContain( + "Complete family coverage cannot omit or retain unknown facts", + ); + expect( + javaScriptSemanticGraphSchema.safeParse(JSON.parse(JSON.stringify(invalid))) + .success, + ).toBe(false); +}); + it("validates evidence context ownership and references", () => { const graph = semanticFixtureGraph(); const alteredContext = { diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 425e5fea5..2e8313167 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -785,6 +785,33 @@ export const semanticObjectPropertyReferences = ( continue; } const name = semanticStaticPropertyKey(property.key, property.computed); + if ( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) { + const selected = key ?? null; + if ( + (selected === null || typeof selected === "object") && + t.isExpression(property.value) + ) { + references.push({ + node: property.value, + path: [selected, ...remaining], + }); + } else if ( + typeof selected === "string" && + !objectExpressionDefinesKey(node, selected) && + t.isExpression(property.value) + ) { + references.push({ + node: property.value, + path: [selected, ...remaining], + }); + } + continue; + } const isGetter = t.isObjectMethod(property) && property.kind === "get"; const isSetter = t.isObjectMethod(property) && property.kind === "set"; if ( diff --git a/src/domain/javascript/javascriptSemanticQuery.ts b/src/domain/javascript/javascriptSemanticQuery.ts index a101cd934..7b883521a 100644 --- a/src/domain/javascript/javascriptSemanticQuery.ts +++ b/src/domain/javascript/javascriptSemanticQuery.ts @@ -250,11 +250,24 @@ const relevantUnknownFrontiers = ( return graph.unknowns.filter( (unknown) => (unknown.node_id === null || nodeIds.has(unknown.node_id)) && - (allowed === null || - unknown.relation_kinds.some((relation) => allowed.has(relation))), + unknown.relation_kinds.some( + (relation) => + directionAllowsRelation(input.direction, relation) && + (allowed === null || allowed.has(relation)), + ), ); }; +const directionAllowsRelation = ( + direction: JavaScriptSemanticQueryInput["direction"], + relation: JavaScriptSemanticGraphRelation["relation"], +): boolean => + direction === "callers" + ? relation === "calls" + : direction === "ownership" + ? ownershipRelation(relation) + : true; + const relevantCandidateRelationCount = ( graph: JavaScriptSemanticGraph, nodeIds: ReadonlySet, diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 4e0a2acac..b78bd543a 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -389,6 +389,62 @@ it("composes fallthrough guards across successive conditional returns", () => { ]); }); +it.each([ + ["while", 'inspect((() => { while (flag) { return "loop"; } })());'], + ["for", 'inspect((() => { for (;;) { return "forever"; } })());'], + ["try", 'inspect((() => { try { return "try"; } finally {} })());'], + [ + "switch", + 'inspect((() => { switch (x) { default: return "switch"; } })());', + ], +])( + "keeps unsupported %s return control flow explicitly unknown", + (_name, source) => { + const value = inspectOne(source); + expect( + symbolicVariants(value).some((variant) => variant.kind === "unknown"), + ).toBe(true); + expect(value).not.toEqual({ kind: "literal", value: undefined }); + }, +); + +it("preserves later returns with unresolved conditions after unsupported return flow", () => { + const variants = symbolicVariants( + inspectOne(`inspect((() => { + while (flag) { return "inside"; } + return "after"; + })());`), + ); + + expect(variants.some((variant) => variant.kind === "unknown")).toBe(true); + expect(variants).toContainEqual( + expect.objectContaining({ + kind: "literal", + value: "after", + conditionUnresolved: true, + }), + ); +}); + +it("does not treat loops without owned returns as unknown return flow", () => { + expect( + inspectOne(`inspect((() => { + while (flag) { work(); } + return "after"; + })());`), + ).toEqual({ kind: "literal", value: "after" }); +}); + +it("does not treat nested-function returns as wrapper return flow", () => { + expect( + inspectOne(`inspect((() => { + try { function nested() { return "inner"; } } + catch {} + return "outer"; + })());`), + ).toEqual({ kind: "literal", value: "outer" }); +}); + it("marks return guards unresolved when a nested join cannot be represented", () => { const value = inspectOne(` function choose() { diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index ec1585a1b..9d771df04 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -58,6 +58,17 @@ const isObjectPrototypeSetter = ( !property.shorthand && name === "__proto__"; +const containsOwnedReturn = (root: t.Node): boolean => { + let found = false; + traverseJavaScriptAst(root, { + skip: (node) => found || t.isFunction(node) || t.isClass(node), + enter: (node) => { + if (t.isReturnStatement(node)) found = true; + }, + }); + return found; +}; + /** Demand-driven substitutions for local bindings, closures and literal module tables. */ export class JavaScriptSymbolicBindings { readonly #writes = new Map(); @@ -832,6 +843,24 @@ export class JavaScriptSymbolicBindings { unresolvedFallthrough = true; } } + if ( + t.isWhileStatement(statement) || + t.isDoWhileStatement(statement) || + t.isForStatement(statement) || + t.isForInStatement(statement) || + t.isForOfStatement(statement) || + t.isSwitchStatement(statement) || + t.isTryStatement(statement) + ) { + if (containsOwnedReturn(statement)) { + possible.push( + symbolicUnknown( + `unsupported return control flow: ${statement.type}`, + ), + ); + unresolvedFallthrough = true; + } + } } return { values: possible, fallsThrough: true }; } From 6e344302cb1c7fa36570afe6ec5e1c9994fa9034 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:30 +0800 Subject: [PATCH 07/18] fix(javascript): prove bundled CommonJS getter identity --- .../electronBundledRequireIdentity.test.ts | 32 ++- .../javascriptBundledRequireOrigin.ts | 242 +++++++++++++++++- 2 files changed, 272 insertions(+), 2 deletions(-) diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index 6b963965a..1904daef9 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -7,7 +7,8 @@ import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; const bootstrap = (external: string, consumer: string, entry = "n(100);") => `(()=>{var m={45840:${external},100:(e,t,n)=>{"use strict";${consumer}}},c={};` + `function n(t){var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};` + - `return m[t](o,o.exports,n),o.exports}${entry}})();`; + `return m[t](o,o.exports,n),o.exports}` + + `n.n=e=>e&&e.__esModule?()=>e.default:()=>e;${entry}})();`; const EXTERNAL = 'e=>{"use strict";e.exports=require("electron")}'; const CONSUMER = @@ -18,6 +19,35 @@ const electronOf = (source: string) => analyzeJavaScriptStaticSource(source).electron; describe("Electron identity through Webpack externals", () => { + it("follows the default-export getter produced by webpack n.n", () => { + const source = bootstrap( + EXTERNAL, + 'const s=n.n(n(45840));new (s().BrowserWindow)({webPreferences:{contextIsolation:false}});s().ipcMain.handle("ready",()=>{});', + ); + const electron = electronOf(source); + expect(electron.browser_windows).toHaveLength(1); + expect(electron.ipc).toMatchObject([{ channel: "ready", side: "main" }]); + }); + + it.each([ + [ + "a fake getter", + bootstrap( + EXTERNAL, + "const s=n.n=e=>()=>0;new (s(n(45840))().BrowserWindow)({});", + ), + ], + [ + "a getter replaced while evaluating its argument", + bootstrap( + EXTERNAL, + "new ((n.n=e=>()=>0,n.n(n(45840)))().BrowserWindow)({});", + ), + ], + ])("does not infer Electron through %s", (_name, source) => { + expect(electronOf(source).browser_windows).toEqual([]); + }); + it("follows the loader passed to a module factory", () => { const electron = electronOf(bootstrap(EXTERNAL, CONSUMER)); expect(electron.browser_windows).toMatchObject([ diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 4bec81042..f6e5749f7 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -18,11 +18,14 @@ import { moduleFactory, modulePropertyName, } from "./javascriptStaticAnalysisHelpers.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; /** Loader bindings and literal externals proven for one standalone bootstrap. */ interface BootstrapExternals { /** The runtime loader and every factory parameter it is passed as. */ readonly loaderBindingIds: ReadonlySet; + /** Loader bindings with the canonical n.n getter installed before dispatch. */ + readonly interopGetterBindingIds: ReadonlySet; /** Table keys whose factory only re-exports one unshadowed literal require; null when a key repeats. */ readonly externals: ReadonlyMap; } @@ -47,6 +50,50 @@ export const semanticBundledRequireOrigin = ( ): JavaScriptRequireOrigin | undefined => { const chain = semanticMemberChain(node); const current = chain?.root; + if ( + chain?.members.length === 0 && + t.isCallExpression(current) && + t.isMemberExpression(current.callee) && + semanticStaticPropertyKey( + current.callee.property, + current.callee.computed, + ) === "n" && + t.isIdentifier(current.callee.object) && + current.arguments.length === 1 + ) { + // Webpack's `n.n(module)` returns its default-export getter. For a + // CommonJS external, invoking that getter yields the exact exports object; + // bind its identity to the proven registry result while retaining the + // nested loader call as the source of provenance. + const nested = semanticBundledRequireOrigin(current.arguments[0], state); + const loaderBinding = resolveSemanticBindingState( + state, + current.callee.object, + current.callee.object.name, + ); + const proof = + nested === undefined || loaderBinding === undefined + ? undefined + : bootstrapProofForLoaderCall( + nested.call, + loaderBinding.bindingId, + state, + ); + const nestedCallee = nested?.call.callee; + const loaderBindingId = loaderBinding?.bindingId; + if ( + nested !== undefined && + nested.origin.importedPath.length === 0 && + t.isIdentifier(nestedCallee) && + loaderBindingId !== undefined && + loaderBindingId === + resolveSemanticBindingState(state, nestedCallee, nestedCallee.name) + ?.bindingId && + proof?.loaderBindingIds.has(loaderBindingId) === true && + proof.interopGetterBindingIds.has(loaderBindingId) + ) + return nested; + } if ( chain === undefined || !t.isCallExpression(current) || @@ -89,6 +136,191 @@ export const semanticBundledRequireOrigin = ( return undefined; }; +/** Prove the source-local n.n getter and reject visible replacements. */ +const canonicalWebpackInteropBindings = ( + loaderBindingIds: ReadonlySet, + bootstrapBody: t.BlockStatement, + state: JavaScriptSemanticAnalysisState, +): ReadonlySet => { + const writes = new Map(); + let reflectiveWrite = false; + traverseJavaScriptAst(state.program, { + enter: (node) => { + if (t.isAssignmentExpression(node) && t.isMemberExpression(node.left)) { + const target = node.left; + const bindingId = t.isIdentifier(target.object) + ? resolveSemanticBindingState( + state, + target.object, + target.object.name, + )?.bindingId + : undefined; + if ( + semanticStaticPropertyKey(target.property, target.computed) === "n" && + bindingId !== undefined && + loaderBindingIds.has(bindingId) + ) + writes.set(bindingId, [...(writes.get(bindingId) ?? []), node]); + } + if ( + !t.isCallExpression(node) || + !t.isMemberExpression(node.callee) || + !t.isIdentifier(node.callee.object, { name: "Object" }) || + ![ + "defineProperty", + "defineProperties", + "assign", + "setPrototypeOf", + ].includes( + semanticStaticPropertyKey( + node.callee.property, + node.callee.computed, + ) ?? "", + ) || + !t.isIdentifier(node.arguments[0]) + ) + return; + reflectiveWrite ||= loaderBindingIds.has( + resolveSemanticBindingState( + state, + node.arguments[0], + node.arguments[0].name, + )?.bindingId ?? "", + ); + }, + }); + const proven = new Set(); + if (reflectiveWrite) return proven; + const allWrites = [...writes.values()].flat(); + const [write] = allWrites; + if ( + allWrites.length === 1 && + write !== undefined && + isInstalledBeforeEntryDispatch( + write, + bootstrapBody, + loaderBindingIds, + state, + ) && + isCanonicalGetter(write, state) + ) + for (const bindingId of loaderBindingIds) proven.add(bindingId); + return proven; +}; + +const isCanonicalGetter = ( + write: t.AssignmentExpression, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if ( + !t.isArrowFunctionExpression(write.right) || + write.right.params.length !== 1 || + !t.isIdentifier(write.right.params[0]) + ) + return false; + const parameter = write.right.params[0]; + const parameterBinding = resolveSemanticBindingState( + state, + parameter, + parameter.name, + ); + const body = write.right.body; + return ( + parameterBinding !== undefined && + t.isConditionalExpression(body) && + t.isLogicalExpression(body.test, { operator: "&&" }) && + sameBinding(body.test.left, parameterBinding.bindingId, state) && + t.isMemberExpression(body.test.right) && + semanticStaticPropertyKey( + body.test.right.property, + body.test.right.computed, + ) === "__esModule" && + sameBinding(body.test.right.object, parameterBinding.bindingId, state) && + getterReturnsDefault(body.consequent, parameterBinding.bindingId, state) && + getterReturnsModule(body.alternate, parameterBinding.bindingId, state) + ); +}; + +const isInstalledBeforeEntryDispatch = ( + write: t.AssignmentExpression, + body: t.BlockStatement, + loaderBindingIds: ReadonlySet, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const statement = state.parentsByNode.get(write); + if (!t.isExpressionStatement(statement)) return false; + const writeIndex = body.body.indexOf(statement); + if (writeIndex < 0) return false; + let entryIndex = -1; + for (let index = 0; index < body.body.length; index += 1) { + const topLevel = body.body[index]; + if (topLevel === undefined) continue; + traverseJavaScriptAst(topLevel, { + enter: (node, _parent, ancestors) => { + if ( + entryIndex < 0 && + t.isCallExpression(node) && + t.isIdentifier(node.callee) && + ancestors().every((ancestor) => !t.isFunction(ancestor)) && + loaderBindingIds.has( + resolveSemanticBindingState(state, node.callee, node.callee.name) + ?.bindingId ?? "", + ) + ) + entryIndex = index; + }, + }); + if (entryIndex >= 0) break; + } + return entryIndex > writeIndex; +}; + +const bootstrapProofForLoaderCall = ( + call: t.CallExpression, + loaderBindingId: string, + state: JavaScriptSemanticAnalysisState, +): BootstrapExternals | undefined => { + for ( + let ancestor = state.parentsByNode.get(call); + ancestor !== undefined; + ancestor = state.parentsByNode.get(ancestor) + ) { + if (!t.isCallExpression(ancestor)) continue; + const proof = bootstrapExternals(ancestor, state); + if (proof?.loaderBindingIds.has(loaderBindingId)) return proof; + } + return undefined; +}; + +const sameBinding = ( + node: t.Node, + bindingId: string, + state: JavaScriptSemanticAnalysisState, +): boolean => + t.isIdentifier(node) && + resolveSemanticBindingState(state, node, node.name)?.bindingId === bindingId; + +const getterReturnsDefault = ( + node: t.Node, + bindingId: string, + state: JavaScriptSemanticAnalysisState, +): boolean => + t.isArrowFunctionExpression(node) && + node.params.length === 0 && + t.isMemberExpression(node.body) && + semanticStaticPropertyKey(node.body.property, node.body.computed) === + "default" && + sameBinding(node.body.object, bindingId, state); + +const getterReturnsModule = ( + node: t.Node, + bindingId: string, + state: JavaScriptSemanticAnalysisState, +): boolean => + t.isArrowFunctionExpression(node) && + node.params.length === 0 && + sameBinding(node.body, bindingId, state); + const bootstrapExternals = ( call: t.CallExpression, state: JavaScriptSemanticAnalysisState, @@ -153,7 +385,15 @@ const proveBootstrapExternals = ( if (externals.has(key)) externals.set(key, null); else externals.set(key, specifier ?? null); } - return { loaderBindingIds, externals }; + return { + loaderBindingIds, + interopGetterBindingIds: canonicalWebpackInteropBindings( + loaderBindingIds, + bootstrap.body, + state, + ), + externals, + }; }; /** From 88afcc738bff8ea2023b504e9ce2254d8bc8ba5a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:30 +0800 Subject: [PATCH 08/18] fix(javascript): retain endpoint candidates through graph projection --- .../JavaScriptArtifactGraphContext.ts | 4 + .../JavaScriptArtifactGraphFindings.ts | 47 +++++++++--- ...riptStaticAnalysis.routeProperties.test.ts | 32 ++++++++ .../javascriptStaticAnalysisCalls.ts | 5 +- src/domain/webBundleAnalyzer.test.ts | 32 ++++++++ src/domain/webBundleAnalyzerAst.test.ts | 27 +++++++ src/domain/webBundleAnalyzerAst.ts | 11 ++- src/domain/webBundleAnalyzerInspection.ts | 19 ++--- .../javascriptArtifactGraphEndpoints.test.ts | 76 +++++++++++++++++++ 9 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts create mode 100644 src/domain/webBundleAnalyzerAst.test.ts create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 1748d9bc6..7bafe0069 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -77,6 +77,7 @@ export interface AstEdgeInput { /** Inferred static relationship edge values. */ export interface InferenceEdgeInput extends AstEdgeInput { readonly limitations?: readonly string[]; + readonly confidence?: "high" | "medium" | "low"; readonly relation: | "imports" | "loads" @@ -194,6 +195,9 @@ export const addStaticInferenceEdge = ( range: input.range, operation: "map-static-relationship", coverage: input.coverage, + ...(input.confidence === undefined + ? {} + : { confidence: input.confidence }), ...(input.limitations === undefined ? {} : { limitations: input.limitations }), diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 9d003c251..57c3961ca 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -201,10 +201,17 @@ const addEndpoint = ( input.value.module_runtime, ) ?? input.asset; const key = compactStringIdentityKey(input.value.value); - const label = `${input.value.kind} endpoint`; + const candidate = input.value.interpretation === "candidate"; + const candidateLimitation = + "The unbound method-like argument is only a candidate; its receiver does not establish a network call."; + const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`; const node = context.accumulator.addNode({ kind: "endpoint", - identity: artifactLocalIdentity(input.file.sha256, input.value.kind, key), + identity: artifactLocalIdentity( + input.file.sha256, + input.value.kind, + JSON.stringify([input.value.interpretation ?? "literal", key]), + ), observations: [ { label: @@ -213,14 +220,25 @@ const addEndpoint = ( endpoint_kind: input.value.kind, value: input.value.value, mechanism: input.value.mechanism, + ...(candidate ? { interpretation: input.value.interpretation } : {}), }, - evidence: astObservationEvidence({ - sha256: input.file.sha256, - path: input.file.path, - range: input.value.location, - operation: "discover-static-endpoint", - coverage: input.coverage, - }), + evidence: candidate + ? staticInferenceEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "classify-static-endpoint-candidate", + coverage: input.coverage, + confidence: "low", + limitations: [candidateLimitation], + }) + : astObservationEvidence({ + sha256: input.file.sha256, + path: input.file.path, + range: input.value.location, + operation: "discover-static-endpoint", + coverage: input.coverage, + }), }, ], }); @@ -231,7 +249,16 @@ const addEndpoint = ( range: input.value.location, coverage: input.coverage, relation: input.value.kind === "route" ? "exposes" : "calls", - properties: { mechanism: input.value.mechanism }, + properties: { + mechanism: input.value.mechanism, + ...(candidate ? { interpretation: input.value.interpretation } : {}), + }, + ...(candidate + ? { + confidence: "low" as const, + limitations: [candidateLimitation], + } + : {}), }); }; diff --git a/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts new file mode 100644 index 000000000..cc8d60a62 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysis.routeProperties.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("reads exact route-property templates and leaves interpolated values unknown", () => { + const analysis = analyzeJavaScriptStaticSource(` + const routes = [ + { path: \`settings\` }, + { route: \`users/\${userId}\` }, + { path: "/login" }, + ]; + `); + + expect( + analysis.endpoints.map(({ value, mechanism, interpretation }) => ({ + value, + mechanism, + interpretation, + })), + ).toEqual([ + { + value: "settings", + mechanism: "property:path", + interpretation: undefined, + }, + { + value: "/login", + mechanism: "property:path", + interpretation: undefined, + }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index b9bc3c96e..a37896589 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -420,11 +420,12 @@ export const inspectRouteProperty = ( context: FindingContext, ): void => { const key = semanticStaticPropertyName(node.key, node.computed); - if ((key === "path" || key === "route") && t.isStringLiteral(node.value)) + const value = stringValue(node.value); + if ((key === "path" || key === "route") && value !== undefined) addEndpoint(context, { node, kind: "route", - value: node.value.value, + value, mechanism: `property:${key}`, }); }; diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index 0450bc774..bed9cb6f3 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -902,6 +902,38 @@ const inspection = (source: string) => limitations: [], }); +it("recognizes expression-free template literals as route properties", () => { + const result = analyzeCapturedWebBundle( + inspection(` + const routes = [ + { path: \`score-board\`, component: ScoreBoard }, + { route: \`administration\`, component: Admin }, + { path: \`/user/\${name}\`, component: Dynamic }, + ]; + `), + ); + + expect(result.observations.routes.map(({ value }) => value)).toEqual([ + "score-board", + "administration", + ]); +}); + +it("uses the shared parser for decorated captured scripts", () => { + const result = analyzeCapturedWebBundle( + inspection(`@sealed class Widget {} fetch('/decorated');`), + ); + + expect(result.completeness).toMatchObject({ + status: "complete", + parsed_scripts: 1, + parse_failures: 0, + }); + expect(result.observations.endpoints).toEqual([ + expect.objectContaining({ value: "/decorated" }), + ]); +}); + it("keeps bare module locations unresolved without inventing URL-relative paths", () => { const result = analyzeCapturedWebBundle( inspection(` diff --git a/src/domain/webBundleAnalyzerAst.test.ts b/src/domain/webBundleAnalyzerAst.test.ts new file mode 100644 index 000000000..b3210d94f --- /dev/null +++ b/src/domain/webBundleAnalyzerAst.test.ts @@ -0,0 +1,27 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js"; +import { objectString } from "./webBundleAnalyzerAst.js"; + +it("reads WebMCP declaration strings through the exact-literal owner", () => { + const file = parseJavaScriptSource( + "const declaration = { name: `catalog`, description: `Browse catalog` }; const dynamic = { name: `catalog ${scope}` };", + ); + const declaration = file?.program.body[0]; + if (!t.isVariableDeclaration(declaration)) + throw new Error("Expected declaration fixture"); + const object = declaration.declarations[0]?.init; + if (!t.isObjectExpression(object)) + throw new Error("Expected object expression fixture"); + const dynamicDeclaration = file?.program.body[1]; + if (!t.isVariableDeclaration(dynamicDeclaration)) + throw new Error("Expected dynamic declaration fixture"); + const dynamic = dynamicDeclaration.declarations[0]?.init; + if (!t.isObjectExpression(dynamic)) + throw new Error("Expected dynamic object expression fixture"); + + expect(objectString(object, "name")).toBe("catalog"); + expect(objectString(object, "description")).toBe("Browse catalog"); + expect(objectString(dynamic, "name")).toBeUndefined(); +}); diff --git a/src/domain/webBundleAnalyzerAst.ts b/src/domain/webBundleAnalyzerAst.ts index 0decf4370..ce98e1b95 100644 --- a/src/domain/webBundleAnalyzerAst.ts +++ b/src/domain/webBundleAnalyzerAst.ts @@ -1,7 +1,10 @@ import * as t from "@babel/types"; import { sanitizeBrowserUrl } from "./browserObservation.js"; -import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyName, +} from "./javascript/javascriptAstValues.js"; /** Pick the endpoint argument for common network-call patterns. */ @@ -28,7 +31,11 @@ export const objectString = ( name: string, ): string | undefined => { const value = objectValue(object, name); - return t.isStringLiteral(value) ? value.value : undefined; + if (value === undefined) return undefined; + const literal = readExactJavaScriptLiteral(value); + return literal.found && typeof literal.value === "string" + ? literal.value + : undefined; }; /** Whether module syntax supplies a URL location without a package/import-map resolver. */ diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 2c6b8c7ed..2271d2166 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -1,4 +1,3 @@ -import { parse } from "@babel/parser"; import * as t from "@babel/types"; import { sanitizeEndpointCandidate } from "./browserObservation.js"; @@ -29,6 +28,7 @@ import { isUrlLikeModuleSpecifier, } from "./webBundleAnalyzerAst.js"; import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js"; +import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js"; type BundleObservations = WebBundleAnalysis["observations"]; type ChunkEdge = BundleObservations["chunks"]["edges"][number]; @@ -58,16 +58,8 @@ export const analyzeScript = ( script: IncludedScript, accumulator: AnalysisAccumulator, ): void => { - let file: ReturnType; - try { - file = parse(script.source.artifact.text, { - sourceType: "unambiguous", - errorRecovery: true, - plugins: ["jsx", "typescript"], - }); - } catch (cause: unknown) { - // Unparseable scripts are counted; the failure needs no extra detail. - void cause; + const file = parseJavaScriptSource(script.source.artifact.text); + if (file === null) { accumulator.failedScripts.push(script.script_key); return; } @@ -216,11 +208,12 @@ const inspectRouteProperty = ( accumulator: AnalysisAccumulator, ): void => { const key = semanticStaticPropertyName(node.key, node.computed); - if ((key === "path" || key === "route") && t.isStringLiteral(node.value)) + const route = stringValue(node.value); + if ((key === "path" || key === "route") && route !== undefined) addFinding({ collection: accumulator.routes, script, - rawValue: node.value.value, + rawValue: route, mechanism: `property:${key}`, node, accumulator, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts new file mode 100644 index 000000000..2976625e4 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -0,0 +1,76 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js"; +import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js"; +import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; + +it("keeps ambiguous endpoint candidates inferred through artifact graph projection", async () => { + const root = await createTestTempDirectory("rea-js-endpoint-certainty-"); + await writeFile(join(root, "app.js"), 'form.get("shared"); fetch("shared");'); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const endpoints = graph.nodes.filter(({ kind }) => kind === "endpoint"); + const candidate = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => properties.interpretation === "candidate", + ), + ); + const observed = endpoints.find(({ observations }) => + observations.some( + ({ properties }) => + properties.value === "shared" && + properties.interpretation === undefined, + ), + ); + + expect(candidate).toBeDefined(); + expect(observed).toBeDefined(); + expect(candidate?.node_id).not.toBe(observed?.node_id); + const candidateObservation = candidate?.observations[0]; + expect(candidateObservation?.properties).toMatchObject({ + value: "shared", + interpretation: "candidate", + }); + expect(candidateObservation?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + confidence: "low", + location: { + available: true, + value: { kind: "source-range", source: "app.js" }, + }, + }); + expect(candidateObservation?.evidence.limitations).toEqual( + expect.arrayContaining([expect.stringContaining("only a candidate")]), + ); + expect(observed?.observations[0]?.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + confidence: "exact", + }); + const candidateEdge = graph.edges.find( + ({ target_node_id }) => target_node_id === candidate?.node_id, + ); + expect(candidateEdge?.properties.interpretation).toBe("candidate"); + expect(candidateEdge?.evidence).toMatchObject({ + state: "inferred", + confidence: "low", + }); + expect(candidateEdge?.evidence.limitations).toEqual( + expect.arrayContaining([expect.stringContaining("only a candidate")]), + ); + } finally { + await reader.close(); + await rm(root, { recursive: true, force: true }); + } +}); From 29a2d13994708789fa3104437f1f2f93c6a34c36 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:56 +0800 Subject: [PATCH 09/18] fix(captures): validate SAZ XML and classify expansion limits --- licenses/npm-dependencies.txt | 12 +++++- package-lock.json | 19 +++++++++ package.json | 1 + src/browser/history/CaptureFormatError.ts | 7 +++- .../history/HistoricalCaptureDecoder.ts | 13 +++++++ src/browser/history/SazCapture.ts | 4 +- src/browser/history/SazMetadata.test.ts | 25 ++++++++++++ src/browser/history/SazMetadata.ts | 39 +++++++++++++++---- .../browser/historicalCaptureDecoder.test.ts | 17 ++++++++ 9 files changed, 126 insertions(+), 11 deletions(-) diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 794d5287e..5026b01ff 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 719; runtime: 175. +Locked package entries: 721; runtime: 177. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -3000,6 +3000,11 @@ safer-buffer@2.1.2 | development | MIT Package: https://www.npmjs.com/package/safer-buffer/v/2.1.2 Archive: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz Integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +saxes@6.0.0 | runtime | ISC + Lockfile path: node_modules/saxes + Package: https://www.npmjs.com/package/saxes/v/6.0.0 + Archive: https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz + Integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== search-insights@2.17.3 | development | MIT Lockfile path: node_modules/search-insights Package: https://www.npmjs.com/package/search-insights/v/2.17.3 @@ -3580,6 +3585,11 @@ xmlbuilder@15.1.1 | runtime | MIT Package: https://www.npmjs.com/package/xmlbuilder/v/15.1.1 Archive: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz Integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== +xmlchars@2.2.0 | runtime | MIT + Lockfile path: node_modules/xmlchars + Package: https://www.npmjs.com/package/xmlchars/v/2.2.0 + Archive: https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz + Integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== yaml@2.9.0 | runtime | ISC Lockfile path: node_modules/yaml Package: https://www.npmjs.com/package/yaml/v/2.9.0 diff --git a/package-lock.json b/package-lock.json index 48764a796..62d4e8f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "pino": "10.4.0", "playwright-core": "1.58.2", "plist": "5.0.0", + "saxes": "6.0.0", "semver": "7.8.5", "smol-toml": "1.9.0", "stream-chain": "4.2.6", @@ -8672,6 +8673,18 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/search-insights": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", @@ -10585,6 +10598,12 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 45e73afc0..c60c1fa99 100644 --- a/package.json +++ b/package.json @@ -341,6 +341,7 @@ "pino": "10.4.0", "playwright-core": "1.58.2", "plist": "5.0.0", + "saxes": "6.0.0", "semver": "7.8.5", "smol-toml": "1.9.0", "stream-chain": "4.2.6", diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts index a31b3057d..c3e4da9b3 100644 --- a/src/browser/history/CaptureFormatError.ts +++ b/src/browser/history/CaptureFormatError.ts @@ -1,7 +1,12 @@ /** Expected malformed, unsupported or oversized producer data at a historical decoder boundary. */ export class CaptureFormatError extends Error { constructor( - readonly reason: "format" | "unsupported" | "input-limit" | "limit", + readonly reason: + | "format" + | "unsupported" + | "input-limit" + | "expanded-limit" + | "limit", message: string, readonly pointer = "", ) { diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index 85a91ef76..992527b88 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -51,6 +51,7 @@ const replySchema = z.discriminatedUnion("ok", [ "format", "unsupported", "input-limit", + "expanded-limit", "resource-limit", "decoder", "limit", @@ -215,6 +216,18 @@ export class HistoricalCaptureDecoder { }, }, ]); + if (reply.reason === "expanded-limit") + throw new AnalysisInputError(OPERATION, undefined, [ + { + path: ["capture_path", reply.pointer], + reason: "out_of_range", + message: reply.message, + expected: { + maximum_expanded_capture_bytes: + WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + }, + ]); if (reply.reason === "resource-limit") throw captureMemoryFailure(input, reply.message); if (reply.reason === "decoder") diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts index 0d597eb5a..054e5c19b 100644 --- a/src/browser/history/SazCapture.ts +++ b/src/browser/history/SazCapture.ts @@ -52,7 +52,7 @@ export const decodeSazCapture = async ( } if (declaredBytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes) throw new CaptureFormatError( - "limit", + "expanded-limit", "SAZ expanded members exceed the 32 MiB complete-evidence budget.", ); inventory.push({ @@ -161,7 +161,7 @@ export const decodeSazCapture = async ( if (cause instanceof CaptureFormatError) throw cause; if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") throw new CaptureFormatError( - "limit", + "expanded-limit", "SAZ archive exceeds the ZIP reader's resource budget.", ); throw new CaptureFormatError( diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts index a5dbbf783..4aa482a7a 100644 --- a/src/browser/history/SazMetadata.test.ts +++ b/src/browser/history/SazMetadata.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { CaptureFormatError } from "./CaptureFormatError.js"; import { decodeSazMetadata } from "./SazMetadata.js"; it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => { @@ -14,6 +15,9 @@ it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => }); it.each([ + "a & b", + "a\u0000b", + "�", "�", '', "�", @@ -22,3 +26,24 @@ it.each([ "SAZ metadata is not well-formed XML.", ); }); + +it("preserves valid comments, CDATA, declarations, and predefined entities", () => { + const xml = + 'A & B'; + const decoded = JSON.stringify(decodeSazMetadata(Buffer.from(xml))); + expect(decoded).toContain("note & literal"); + expect(decoded).toContain("A & B"); + expect(decoded).toContain("C & D"); +}); + +it("rejects DTDs without resolving entities", () => { + expect(() => + decodeSazMetadata( + Buffer.from(']>'), + ), + ).toThrow( + expect.objectContaining({ + reason: "unsupported", + } satisfies Partial), + ); +}); diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts index f9d1b6733..a5dd239bd 100644 --- a/src/browser/history/SazMetadata.ts +++ b/src/browser/history/SazMetadata.ts @@ -1,4 +1,5 @@ import { DOMParser, ParseError, type Node } from "@xmldom/xmldom"; +import { SaxesParser } from "saxes"; import type { JsonValue } from "../../domain/jsonValue.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js"; import { CaptureFormatError } from "./CaptureFormatError.js"; @@ -20,6 +21,7 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { "SAZ metadata must use UTF-8 or BOM-marked UTF-16.", ); } + validateSazXml(xml); let parseFailed = false; let document: ReturnType; try { @@ -44,13 +46,6 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { "SAZ metadata is not well-formed XML.", ); } - // The pinned parser does not resolve external or DTD-defined entities. - // Inspect its parsed node; text inside comments/CDATA is ordinary evidence. - if (document.doctype !== null) - throw new CaptureFormatError( - "unsupported", - "SAZ metadata DTDs are unsupported.", - ); if (parseFailed) throw new CaptureFormatError( "format", @@ -89,3 +84,33 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { }; return visit(document, 0); }; + +/** Validate well-formedness before the DOM parser projects retained evidence. */ +const validateSazXml = (xml: string): void => { + const parser = new SaxesParser({ + defaultXMLVersion: "1.0", + forceXMLVersion: true, + }); + let hasDoctype = false; + parser.on("doctype", () => { + hasDoctype = true; + }); + try { + parser.write(xml).close(); + } catch { + if (hasDoctype) + throw new CaptureFormatError( + "unsupported", + "SAZ metadata DTDs are unsupported.", + ); + throw new CaptureFormatError( + "format", + "SAZ metadata is not well-formed XML.", + ); + } + if (hasDoctype) + throw new CaptureFormatError( + "unsupported", + "SAZ metadata DTDs are unsupported.", + ); +}; diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index e02524f8d..240c04d43 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -365,6 +365,7 @@ it("cancels after snapshot acquisition before launch and removes private input d it.each([ { reason: "input-limit", tag: "AnalysisInputError" }, + { reason: "expanded-limit", tag: "AnalysisInputError" }, { reason: "resource-limit", tag: "AnalysisResourceConstraintError" }, { reason: "decoder", tag: "ProviderAdapterError" }, { reason: "limit", tag: "AnalysisOutputError" }, @@ -425,6 +426,22 @@ it.each([ ], }, }); + } else if (reason === "expanded-limit") { + expect(projected).toMatchObject({ + category: "invalid_input", + details: { + issues: [ + { + path: ["capture_path", "/extension/child"], + reason: "out_of_range", + expected: { + maximum_expanded_capture_bytes: + WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + }, + ], + }, + }); } else if (tag === "ProviderAdapterError") { expect(projected).toMatchObject({ details: { From 55778f20bacffe428e2cddcff81ffe01c0d477ae Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:57 +0800 Subject: [PATCH 10/18] fix(inspector): retain usable targets and cancel owned capture waits --- src/inspector/V8InspectorEndpoint.test.ts | 69 ++++++ src/inspector/V8InspectorEndpoint.ts | 28 ++- .../V8InspectorProvider.partial.test.ts | 223 +++++++++++------- src/inspector/V8InspectorProvider.ts | 51 +++- 4 files changed, 265 insertions(+), 106 deletions(-) create mode 100644 src/inspector/V8InspectorEndpoint.test.ts diff --git a/src/inspector/V8InspectorEndpoint.test.ts b/src/inspector/V8InspectorEndpoint.test.ts new file mode 100644 index 000000000..83a0d20fc --- /dev/null +++ b/src/inspector/V8InspectorEndpoint.test.ts @@ -0,0 +1,69 @@ +import { createServer } from "node:http"; + +import { expect, it } from "vitest"; + +import { V8InspectorProvider } from "./V8InspectorProvider.js"; + +it("keeps connectable siblings when a target omits its direct socket", async () => { + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") { + response.end( + JSON.stringify({ + Browser: "Node.js/v24.18.0", + "Protocol-Version": "1.3", + }), + ); + return; + } + if (request.url === "/json/list") { + const address = server.address(); + if (address === null || typeof address === "string") { + response.writeHead(500).end(); + return; + } + response.end( + JSON.stringify([ + { + id: "connectable", + type: "node", + url: "https://example.test/entry.js", + webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/connectable`, + }, + { + id: "anonymous", + type: "node", + url: "https://example.test/eval.js", + }, + ]), + ); + return; + } + response.writeHead(404).end(); + }); + + try { + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Inspector fixture did not bind a TCP port"); + + const result = await new V8InspectorProvider().listTargets({ + inspector_endpoint: `http://127.0.0.1:${String(address.port)}`, + }); + if (!result.ok) + throw new Error(result.error.message, { cause: result.error }); + + expect(result).toMatchObject({ + ok: true, + value: { + targets: [{ target_id: "connectable" }], + excluded: { unconnectable: 1 }, + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index 53f15e073..a3dcd2357 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -23,7 +23,10 @@ const targetSchema = z type: z.string().min(1), url: z.string(), attached: z.boolean().nullable().optional(), - webSocketDebuggerUrl: z.string().min(1), + // Node may publish anonymous/evaluated targets without a direct socket. + // Keep malformed values local to their target so one row cannot discard + // otherwise usable siblings from the endpoint inventory. + webSocketDebuggerUrl: z.unknown().optional(), }) .passthrough(); const targetsSchema = z.array(targetSchema); @@ -33,7 +36,7 @@ export interface V8InspectorTarget { readonly type: string; readonly url: string; readonly attached: boolean | null; - readonly webSocketUrl: string; + readonly webSocketUrl: string | null; } export interface V8InspectorDiscovery { @@ -68,7 +71,6 @@ export const discoverV8Inspector = async ( endpoint, target.id, target.webSocketDebuggerUrl, - operation, ), })), }; @@ -77,16 +79,14 @@ export const discoverV8Inspector = async ( const validatedInspectorWebSocket = ( endpoint: string, targetId: string, - reported: string, - operation: BrowserObservationOperation, -): string => { + reported: unknown, +): string | null => { + if (typeof reported !== "string" || reported.length === 0) return null; let socket: URL; try { socket = new URL(reported); - } catch (cause: unknown) { - throw new BrowserObservationError(operation, "invalid_endpoint_response", { - cause, - }); + } catch { + return null; } const trusted = new URL(endpoint); const allowedPaths = new Set([ @@ -102,12 +102,16 @@ const validatedInspectorWebSocket = ( urlDeclaresQueryOrFragment(socket) || !allowedPaths.has(socket.pathname) ) - throw new BrowserObservationError(operation, "invalid_endpoint_response"); + return null; socket.hostname = trusted.hostname; return socket.href; }; /** Authorized target plus its durable location. */ -export interface AuthorizedV8InspectorTarget extends V8InspectorTarget { +export interface AuthorizedV8InspectorTarget extends Omit< + V8InspectorTarget, + "webSocketUrl" +> { + readonly webSocketUrl: string; readonly location: JavaScriptRuntimeTargetLocation; } diff --git a/src/inspector/V8InspectorProvider.partial.test.ts b/src/inspector/V8InspectorProvider.partial.test.ts index 0ee635249..6b1ac23c8 100644 --- a/src/inspector/V8InspectorProvider.partial.test.ts +++ b/src/inspector/V8InspectorProvider.partial.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; import { z } from "zod"; @@ -14,9 +14,116 @@ import { V8InspectorProvider } from "./V8InspectorProvider.js"; const failureCases = [ ["disconnect", "disconnected"], ["protocol failure", "protocol_error"], + ["idle protocol failure", "protocol_error"], ["cancellation", "cancelled"], ] as const; +const createPartialInspectorServer = ( + failure: (typeof failureCases)[number][0], + scriptUrl: string, + reportedScriptUrl: string, +) => { + let signalScriptSent: (() => void) | undefined; + const scriptSent = new Promise((resolve) => { + signalScriptSent = resolve; + }); + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") { + response.end( + JSON.stringify({ + Browser: "Node.js/v24.18.0", + "Protocol-Version": "1.3", + "V8-Version": "12.0", + }), + ); + return; + } + if (request.url === "/json/list") { + const address = server.address(); + if (address === null || typeof address === "string") { + response.writeHead(500).end(); + return; + } + response.end( + JSON.stringify([ + { + id: "target-1", + type: "node", + url: scriptUrl, + attached: false, + webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`, + }, + ]), + ); + return; + } + response.writeHead(404).end(); + }); + const sockets = new WebSocketServer({ server, path: "/target-1" }); + sockets.on("connection", (socket) => { + socket.on("message", (raw) => { + const parsed = safeParseJson(raw.toString()); + if (!parsed.ok) throw parsed.cause; + const command = z + .object({ id: z.number(), method: z.string() }) + .parse(parsed.value); + if (command.method === "Runtime.enable") { + socket.send( + JSON.stringify({ + method: "Runtime.executionContextCreated", + params: { + context: { id: 1, name: "main", origin: reportedScriptUrl }, + }, + }), + ); + socket.send( + JSON.stringify({ + method: "Debugger.scriptParsed", + params: { + scriptId: "1", + url: reportedScriptUrl, + executionContextId: 1, + hash: "captured-before-failure", + length: 24, + isModule: false, + }, + }), + ); + socket.send(JSON.stringify({ id: command.id, result: {} })); + return; + } + if (command.method === "Debugger.enable") { + // Receiving the next command proves the preceding events and reply + // were consumed; cancellation needs no timing assumption. + signalScriptSent?.(); + if (failure === "protocol failure") { + socket.send(JSON.stringify({ id: command.id, result: "malformed" })); + } else { + socket.send(JSON.stringify({ id: command.id, result: {} })); + if (failure === "disconnect") socket.close(); + if (failure === "idle protocol failure") { + setTimeout(() => socket.send("{malformed-json"), 20); + setTimeout( + () => + socket.send( + JSON.stringify({ + method: "Debugger.scriptParsed", + params: { scriptId: "late", url: scriptUrl }, + }), + ), + 40, + ); + } + } + return; + } + socket.send(JSON.stringify({ id: command.id, result: {} })); + }); + }); + return { server, sockets, scriptSent }; +}; + it.each(failureCases)( "preserves captured scripts and contexts after %s", async (failure, reason) => { @@ -26,93 +133,11 @@ it.each(failureCases)( const reportedScriptUrl = failure === "cancellation" ? "" : scriptUrl; await writeFile(scriptPath, "globalThis.ready = true;\n"); - let signalScriptSent: (() => void) | undefined; - const scriptSent = new Promise((resolve) => { - signalScriptSent = resolve; - }); - const server = createServer((request, response) => { - response.setHeader("content-type", "application/json"); - if (request.url === "/json/version") { - response.end( - JSON.stringify({ - Browser: "Node.js/v24.18.0", - "Protocol-Version": "1.3", - "V8-Version": "12.0", - }), - ); - return; - } - if (request.url === "/json/list") { - const address = server.address(); - if (address === null || typeof address === "string") { - response.writeHead(500).end(); - return; - } - response.end( - JSON.stringify([ - { - id: "target-1", - type: "node", - url: scriptUrl, - attached: false, - webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`, - }, - ]), - ); - return; - } - response.writeHead(404).end(); - }); - const sockets = new WebSocketServer({ server, path: "/target-1" }); - sockets.on("connection", (socket) => { - socket.on("message", (raw) => { - const parsed = safeParseJson(raw.toString()); - if (!parsed.ok) throw parsed.cause; - const command = z - .object({ id: z.number(), method: z.string() }) - .parse(parsed.value); - if (command.method === "Runtime.enable") { - socket.send( - JSON.stringify({ - method: "Runtime.executionContextCreated", - params: { - context: { id: 1, name: "main", origin: reportedScriptUrl }, - }, - }), - ); - socket.send( - JSON.stringify({ - method: "Debugger.scriptParsed", - params: { - scriptId: "1", - url: reportedScriptUrl, - executionContextId: 1, - hash: "captured-before-failure", - length: 24, - isModule: false, - }, - }), - ); - socket.send(JSON.stringify({ id: command.id, result: {} })); - return; - } - if (command.method === "Debugger.enable") { - // Receiving the next command proves the preceding events and reply - // were consumed; cancellation needs no timing assumption. - signalScriptSent?.(); - if (failure === "protocol failure") { - socket.send( - JSON.stringify({ id: command.id, result: "malformed" }), - ); - } else { - socket.send(JSON.stringify({ id: command.id, result: {} })); - if (failure === "disconnect") socket.close(); - } - return; - } - socket.send(JSON.stringify({ id: command.id, result: {} })); - }); - }); + const { server, sockets, scriptSent } = createPartialInspectorServer( + failure, + scriptUrl, + reportedScriptUrl, + ); try { await new Promise((resolve) => @@ -123,13 +148,19 @@ it.each(failureCases)( throw new Error("Inspector fixture did not bind a TCP port"); const controller = new AbortController(); + const timerSpy = + failure === "disconnect" ? vi.spyOn(globalThis, "setTimeout") : null; + const clearSpy = + failure === "disconnect" ? vi.spyOn(globalThis, "clearTimeout") : null; const executionOptions = - failure === "cancellation" ? { signal: controller.signal } : {}; + failure === "cancellation" || failure === "disconnect" + ? { signal: controller.signal } + : {}; const observing = new V8InspectorProvider().observe( { inspector_endpoint: `http://127.0.0.1:${String(address.port)}`, target_id: "target-1", - observation_ms: 10_000, + observation_ms: failure === "disconnect" ? 12_345 : 10_000, }, executionOptions, ); @@ -139,6 +170,17 @@ it.each(failureCases)( } const result = await observing; + if (failure === "disconnect") { + const observationTimer = timerSpy?.mock.results.find( + (result, index) => timerSpy.mock.calls[index]?.[1] === 12_345, + )?.value; + expect(observationTimer).toBeDefined(); + expect( + clearSpy?.mock.calls.some(([timer]) => timer === observationTimer), + ).toBe(true); + expect(controller.signal.aborted).toBe(false); + } + expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected the Inspector operation to fail"); @@ -167,6 +209,7 @@ it.each(failureCases)( }, }); } finally { + vi.restoreAllMocks(); await new Promise((resolve) => sockets.close(() => resolve())); await new Promise((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 06a413871..bf89e227e 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -94,6 +94,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { ); const allowed: AuthorizedV8InspectorTarget[] = []; const excluded = createInspectorExclusionCounts(); + let unconnectable = 0; for (const target of discovery.targets) { const decision = await authorizeRuntimeTargetLocation(target.url, { type: target.type, @@ -107,7 +108,18 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { excluded.unsupported_location += 1; continue; } - allowed.push({ ...target, location: decision.location }); + if (target.webSocketUrl === null) { + unconnectable += 1; + continue; + } + allowed.push({ + id: target.id, + type: target.type, + url: target.url, + attached: target.attached, + webSocketUrl: target.webSocketUrl, + location: decision.location, + }); } allowed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0, @@ -116,7 +128,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { javascriptRuntimeTargetListSchema.parse({ runtime: discovery.runtime, targets: allowed.map(projectTarget), - excluded: { ...excluded, unconnectable: 0 }, + excluded: { ...excluded, unconnectable }, limitations: describeInspectorTargetLimitations( allowed.some((target) => target.attached === null), ), @@ -367,7 +379,23 @@ const authorizedTarget = async ( "observe_javascript_runtime", "target_not_allowed", ); - return { ...target, location: decision.location }; + if (target.webSocketUrl === null) + throw new BrowserObservationError( + "observe_javascript_runtime", + "invalid_endpoint_response", + { + detail: + "The selected Inspector target has no validated direct WebSocket endpoint.", + }, + ); + return { + id: target.id, + type: target.type, + url: target.url, + attached: target.attached, + webSocketUrl: target.webSocketUrl, + location: decision.location, + }; }; const assertRuntimeKind = ( @@ -515,20 +543,35 @@ const waitForCapture = async ( signal?: AbortSignal, ): Promise => { let removeDisconnect = (): void => undefined; + let removeProtocolFailure = (): void => undefined; const disconnected = new Promise((_resolve, reject) => { removeDisconnect = connection.onDisconnect((error) => reject(error)); }); + const protocolFailure = new Promise((_resolve, reject) => { + removeProtocolFailure = connection.onProtocolFailure((error) => + reject(error), + ); + }); + const timerOwner = new AbortController(); + const timerSignal = + signal === undefined + ? timerOwner.signal + : AbortSignal.any([signal, timerOwner.signal]); try { await Promise.race([ delayWithCancellation( observationMs, "observe_javascript_runtime", - signal, + timerSignal, ), disconnected, + protocolFailure, ]); } finally { + // Cancel only this wait's timer. The caller owns and retains its signal. + timerOwner.abort(); removeDisconnect(); + removeProtocolFailure(); } }; From 443c7aff77c862d19fca296149fce28091ab0592 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:57 +0800 Subject: [PATCH 11/18] fix(evaluation): require explicit REA server attribution --- src/evaluation/CodexAgentEval.ts | 2 +- .../model-evals/codexAgentEval.test.ts | 28 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/evaluation/CodexAgentEval.ts b/src/evaluation/CodexAgentEval.ts index 55ec8333e..744524067 100644 --- a/src/evaluation/CodexAgentEval.ts +++ b/src/evaluation/CodexAgentEval.ts @@ -205,7 +205,7 @@ export const evaluateCodexEvents = ( ); const reaCalls = state.calls.filter( - ({ server }) => server === null || server.toLowerCase() === "rea", + ({ server }) => server?.toLowerCase() === "rea", ); const signatures = new Map(); let targetRevision = 0; diff --git a/tests/evaluation/model-evals/codexAgentEval.test.ts b/tests/evaluation/model-evals/codexAgentEval.test.ts index 6b46d9b1d..910fd7ae6 100644 --- a/tests/evaluation/model-evals/codexAgentEval.test.ts +++ b/tests/evaluation/model-evals/codexAgentEval.test.ts @@ -218,7 +218,23 @@ describe("Codex completed tool call parsing", () => { }, ); - it("preserves legacy completed calls with no server field", () => { + it.each([ + { + label: "missing server identity", + identity: {}, + routed: false, + }, + { + label: "another server", + identity: { server: "other" }, + routed: false, + }, + { + label: "verified REA alias", + identity: { server_name: "rea" }, + routed: true, + }, + ])("routes only $label calls", ({ identity, routed }) => { const metrics = evaluateCodexEvents( [ { @@ -228,16 +244,20 @@ describe("Codex completed tool call parsing", () => { status: "completed", tool: "inspect_managed_artifact", arguments: {}, + ...identity, }, }, ], "inspect_managed_artifact", + { requiredToolSubsequence: ["inspect_managed_artifact"] }, ); expect(metrics).toMatchObject({ - naturalUse: true, - correctFirstTool: true, - reaCalls: [{ server: null }], + naturalUse: routed, + correctFirstTool: routed, + firstTool: routed ? "inspect_managed_artifact" : null, + requiredToolSubsequenceMet: routed, + reaCalls: routed ? [{ server: "rea" }] : [], }); }); }); From eca18e08ac80ac838eef78788bf82429bbb30673 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:57 +0800 Subject: [PATCH 12/18] fix(verifiers): require positive ownership and restore cleanup coverage --- docs/client-verification.md | 5 ++ scripts/fixtures/browser-verifier-site.d.mts | 7 +++ scripts/fixtures/browser-verifier-site.mjs | 4 +- scripts/lib/browser-runtime-e2e.mjs | 2 + .../lib/browser-runtime-fixture-lifecycle.mjs | 2 + scripts/lib/real-hopper-owner-exit.mjs | 37 ++++++++++++- scripts/lib/verifier-run.d.mts | 5 ++ scripts/lib/verifier-run.mjs | 10 ++++ .../lib/windows-ordinary-user-verifier.d.mts | 4 ++ .../lib/windows-ordinary-user-verifier.mjs | 40 ++++++++++++++ scripts/verify-browser-source-maps.mjs | 3 ++ scripts/verify-package-setup.d.mts | 3 ++ scripts/verify-package-setup.mjs | 9 +++- scripts/verify-real-browser.mjs | 54 ++++++++++++++----- scripts/verify-real-ghidra-dos.mjs | 13 +++-- scripts/verify-wasm-artifact.mjs | 54 ++++++++++++++++--- .../browser/browserProcessStartup.test.ts | 23 ++++++++ .../browser/browserVerifierFixture.test.ts | 30 +++++++++++ .../filesystem/packageClientBackup.test.ts | 20 ++++++- tests/boundary/process/verifierRun.test.ts | 38 +++++++++++++ 20 files changed, 330 insertions(+), 33 deletions(-) create mode 100644 scripts/fixtures/browser-verifier-site.d.mts create mode 100644 scripts/lib/windows-ordinary-user-verifier.d.mts create mode 100644 scripts/lib/windows-ordinary-user-verifier.mjs create mode 100644 tests/boundary/browser/browserVerifierFixture.test.ts create mode 100644 tests/boundary/process/verifierRun.test.ts diff --git a/docs/client-verification.md b/docs/client-verification.md index 38d0bf399..575cc61d2 100644 --- a/docs/client-verification.md +++ b/docs/client-verification.md @@ -377,6 +377,11 @@ Such a stall leaves the initial page unloaded with empty or placeholder CDP URLs it does not establish permission to capture that page. See [the keyring diagnosis](https://github.com/morluto/rea/issues/1384#issuecomment-6093781686). +On Windows, browser verifier lanes that launch an owned fixture require a +standard, non-elevated caller token. They inspect the verifier caller before +allocating fixture resources and refuse elevated or unobservable tokens; they do +not change caller authority or claim to inspect the browser process token. + `verify:browser` also captures a source-owned noise canvas as a real PNG above 8 MiB through the CLI and stdio MCP, with complete byte/digest parity and real PNG decoding. Its SDK client explicitly permits the larger inline JSON response; diff --git a/scripts/fixtures/browser-verifier-site.d.mts b/scripts/fixtures/browser-verifier-site.d.mts new file mode 100644 index 000000000..75827f3d3 --- /dev/null +++ b/scripts/fixtures/browser-verifier-site.d.mts @@ -0,0 +1,7 @@ +export interface BrowserVerifierSite { + readonly origin: string; + triggerSessionNavigation(): void; + close(): Promise; +} + +export function startBrowserVerifierSite(): Promise; diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 059a63599..40281bc64 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -184,11 +184,13 @@ export async function startBrowserVerifierSite() { async close() { for (const client of webSockets.clients) client.terminate(); await new Promise((resolve) => webSockets.close(resolve)); - await new Promise((resolve, reject) => + const serverClosed = new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error), ), ); + server.closeAllConnections(); + await serverClosed; }, }; } diff --git a/scripts/lib/browser-runtime-e2e.mjs b/scripts/lib/browser-runtime-e2e.mjs index b35cde09a..78d9d4615 100644 --- a/scripts/lib/browser-runtime-e2e.mjs +++ b/scripts/lib/browser-runtime-e2e.mjs @@ -14,6 +14,7 @@ import { parseEvidence } from "../../dist/domain/evidence.js"; import { webExecutionSchema } from "../../dist/domain/webExecution.js"; import { webEventListenersSchema } from "../../dist/domain/webEventListeners.js"; import { startBrowserRuntimeSite } from "../fixtures/browser-runtime-site.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./windows-ordinary-user-verifier.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; import { startRuntimeFixtureBrowser, @@ -37,6 +38,7 @@ export async function verifyBrowserRuntime( executable, entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), ) { + await requireOrdinaryWindowsVerifierCaller(); await access(executable); const site = await startBrowserRuntimeSite(); let fixture; diff --git a/scripts/lib/browser-runtime-fixture-lifecycle.mjs b/scripts/lib/browser-runtime-fixture-lifecycle.mjs index 88a76e573..78709646d 100644 --- a/scripts/lib/browser-runtime-fixture-lifecycle.mjs +++ b/scripts/lib/browser-runtime-fixture-lifecycle.mjs @@ -12,9 +12,11 @@ import { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, waitForBrowserDevtoolsPort, } from "../../dist/browser/BrowserProcessStartup.js"; +import { requireOrdinaryWindowsVerifierCaller } from "./windows-ordinary-user-verifier.mjs"; /** Launch the synthetic fixture browser and await CDP in its own verified process group. */ export async function startRuntimeFixtureBrowser(executable, origin) { + await requireOrdinaryWindowsVerifierCaller(); const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-")); let owned; try { diff --git a/scripts/lib/real-hopper-owner-exit.mjs b/scripts/lib/real-hopper-owner-exit.mjs index 300b35ada..ff59b9586 100644 --- a/scripts/lib/real-hopper-owner-exit.mjs +++ b/scripts/lib/real-hopper-owner-exit.mjs @@ -10,6 +10,7 @@ import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership import { observeProcessStartIdentity, signalProcessWithStartIdentity, + systemProcessOwnershipHost, } from "../../dist/process/ProcessOwnershipObservation.js"; import { startUnrelatedHopperSentinel } from "./unrelated-hopper-sentinel.mjs"; import { @@ -31,6 +32,11 @@ export async function verifyLinuxHopperOwnerExit({ const outcomes = []; try { for (const mode of ["graceful", "SIGKILL"]) { + const baseline = await systemProcessOwnershipHost.captureBaseline?.(); + assert.ok( + baseline, + "Could not capture pre-launch process ownership baseline", + ); const transport = new StdioClientTransport({ command: serverCommand, args: serverArgs, @@ -47,6 +53,18 @@ export async function verifyLinuxHopperOwnerExit({ assert.ok(ownerPid !== null); const owner = await observeProcessStartIdentity(ownerPid); assert.equal(owner?.state, "readable"); + const ownerProcess = ( + await systemProcessOwnershipHost.listProcesses() + ).find(({ pid }) => pid === ownerPid); + assert.ok( + ownerProcess, + "Could not observe the launched MCP process group", + ); + assert.equal(typeof ownerProcess.processGroupId, "number"); + const relation = { + leaderPid: ownerPid, + processGroupId: ownerProcess.processGroupId, + }; requireMcpLifecycleResult( await client.callTool( { @@ -75,7 +93,17 @@ export async function verifyLinuxHopperOwnerExit({ const runId = status.analysis_run?.run_id; assert.equal(typeof runId, "string"); assert.equal(status.analysis_provider_binding?.provider.id, "hopper"); - assert.equal((await verifyNoTokenOwnedProcesses(runId)).cleaned, false); + assert.equal( + ( + await verifyNoTokenOwnedProcesses( + runId, + systemProcessOwnershipHost, + baseline, + relation, + ) + ).cleaned, + false, + ); if (mode === "graceful") await client.close(); else { assert.equal( @@ -90,7 +118,12 @@ export async function verifyLinuxHopperOwnerExit({ const deadline = Date.now() + 10_000; let cleanup; do { - cleanup = await verifyNoTokenOwnedProcesses(runId); + cleanup = await verifyNoTokenOwnedProcesses( + runId, + systemProcessOwnershipHost, + baseline, + relation, + ); if (cleanup.cleaned) break; await delay(100); } while (Date.now() < deadline); diff --git a/scripts/lib/verifier-run.d.mts b/scripts/lib/verifier-run.d.mts index 1e9d41324..9b8a71668 100644 --- a/scripts/lib/verifier-run.d.mts +++ b/scripts/lib/verifier-run.d.mts @@ -39,3 +39,8 @@ export function createVerifierRun(): VerifierRunStart; export function completeVerifierRun( run: VerifierRunStart, ): Promise; + +/** Require positive process-lineage evidence before claiming cleanup complete. */ +export function assertVerifierCleanupComplete( + processLineage: VerifierProcessLineage, +): void; diff --git a/scripts/lib/verifier-run.mjs b/scripts/lib/verifier-run.mjs index 0b8f08919..23d8edbbf 100644 --- a/scripts/lib/verifier-run.mjs +++ b/scripts/lib/verifier-run.mjs @@ -33,6 +33,16 @@ export const completeVerifierRun = async (run) => { }; }; +/** Require positive process-lineage evidence before claiming cleanup complete. */ +export const assertVerifierCleanupComplete = (processLineage) => { + if (processLineage.status !== "verified") + throw new Error( + `Verifier cleanup could not be verified: ${processLineage.reason}`, + ); + if (processLineage.descendants.length !== 0) + throw new Error("Owned descendants survived verifier cleanup"); +}; + const observeVerifierLineage = async (run, observedAt) => { if (process.platform === "win32") return unavailableLineage( diff --git a/scripts/lib/windows-ordinary-user-verifier.d.mts b/scripts/lib/windows-ordinary-user-verifier.d.mts new file mode 100644 index 000000000..2fde877f1 --- /dev/null +++ b/scripts/lib/windows-ordinary-user-verifier.d.mts @@ -0,0 +1,4 @@ +export declare const assertOrdinaryWindowsVerifierCaller: ( + token: unknown, +) => void; +export declare const requireOrdinaryWindowsVerifierCaller: () => Promise; diff --git a/scripts/lib/windows-ordinary-user-verifier.mjs b/scripts/lib/windows-ordinary-user-verifier.mjs new file mode 100644 index 000000000..496a429da --- /dev/null +++ b/scripts/lib/windows-ordinary-user-verifier.mjs @@ -0,0 +1,40 @@ +/** Require fixture verifiers on Windows to use the caller's ordinary-user token. */ +export const assertOrdinaryWindowsVerifierCaller = (token) => { + if ( + typeof token !== "object" || + token === null || + !("administrator" in token) || + !("elevated" in token) || + token.administrator !== false || + token.elevated !== false + ) { + const integrity = + typeof token === "object" && + token !== null && + "integrityLevel" in token && + Number.isSafeInteger(token.integrityLevel) + ? ` (integrity level ${String(token.integrityLevel)})` + : ""; + throw new Error( + "Windows browser verifier requires an ordinary, non-elevated user token" + + `${integrity}. Rerun it from a standard-user Windows session; the verifier will not change caller authority.`, + ); + } +}; + +/** Observe the native caller token before a Windows browser fixture allocates resources. */ +export const requireOrdinaryWindowsVerifierCaller = async () => { + if (process.platform !== "win32") return; + let token; + try { + const { requireWindowsNativeAuthority } = + await import("../../dist/windows/WindowsNativeLoader.js"); + token = requireWindowsNativeAuthority().call("process_caller_token", []); + } catch (cause) { + throw new Error( + "Windows browser verifier could not observe its caller token; use the verified Windows x64 native artifact and rerun from a standard-user session.", + { cause }, + ); + } + assertOrdinaryWindowsVerifierCaller(token); +}; diff --git a/scripts/verify-browser-source-maps.mjs b/scripts/verify-browser-source-maps.mjs index a8b53e504..a4e7c2a26 100644 --- a/scripts/verify-browser-source-maps.mjs +++ b/scripts/verify-browser-source-maps.mjs @@ -4,6 +4,9 @@ import { isAbsolute } from "node:path"; import { verifyBrowserSourceMap } from "./lib/browser-source-map-e2e.mjs"; import { verifyBrowserNestedSourceMaps } from "./lib/browser-nested-source-map-e2e.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; + +await requireOrdinaryWindowsVerifierCaller(); const executable = process.env.REA_BROWSER_EXECUTABLE; if (!executable || !isAbsolute(executable)) throw new Error( diff --git a/scripts/verify-package-setup.d.mts b/scripts/verify-package-setup.d.mts index 2c7244291..d6f0ce53e 100644 --- a/scripts/verify-package-setup.d.mts +++ b/scripts/verify-package-setup.d.mts @@ -4,3 +4,6 @@ export function assertClientConfig( configPath: string, original: Uint8Array, ): Promise; + +/** Return true only when stty output positively reports canonical input and echo. */ +export function terminalModesRestored(termios: unknown): boolean; diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index 44f28c142..ad23548cb 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -22,6 +22,12 @@ import { import { parse as parseJsonc } from "jsonc-parser"; import { skillReferenceIssues } from "./lib/docs-facts.mjs"; +export const terminalModesRestored = (termios) => { + if (typeof termios !== "string") return false; + const modes = new Set(termios.trim().split(/\s+/u)); + return modes.has("icanon") && modes.has("echo"); +}; + const OPENCODE_ORIGINAL = `{ // OpenCode preferences must survive setup. "model": "provider/model", @@ -131,8 +137,7 @@ const verifyInteractiveSetup = async ({ childResult?.status !== 0 || !output.includes("\u001b[?25h") || (process.platform !== "win32" && - (!childResult.termios?.includes("icanon") || - !childResult.termios?.includes("echo"))) || + !terminalModesRestored(childResult.termios)) || !output.includes("No changes were made.") ) { finish( diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 3f793d82c..28c53067a 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -39,6 +39,8 @@ import { verifyScenarioFailureEvidence, } from "./lib/browser-scenario-verifier.mjs"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; +import { closeRuntimeFixtureResources } from "./lib/browser-runtime-fixture-lifecycle.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs"; import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs"; import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs"; @@ -58,13 +60,17 @@ process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE; process.env.REA_BROWSER_VERIFIER_URL = SCENARIO_URL_SECRET_VALUE; const verifierRun = createVerifierRun(); +await requireOrdinaryWindowsVerifierCaller(); const executable = await browserExecutable(); -const profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); -const site = await startBrowserVerifierSite(); +let profile; +let site; let browser; let pageProxy; let report; +let verificationError; try { + profile = await mkdtemp(join(tmpdir(), "rea-real-browser-")); + site = await startBrowserVerifierSite(); browser = spawn( executable, [ @@ -409,19 +415,39 @@ try { scenarioServiceWorkers, verified: true, }; +} catch (cause) { + verificationError = cause; + throw cause; } finally { - process.stderr.write("Browser verifier cleanup: closing page proxy\n"); - if (pageProxy !== undefined) await pageProxy.close(); - process.stderr.write("Browser verifier cleanup: stopping owned Chrome\n"); - if (browser !== undefined) await stopProcess(browser); - process.stderr.write("Browser verifier cleanup: closing fixture site\n"); - await site.close(); - await rm(profile, { - recursive: true, - force: true, - maxRetries: 10, - retryDelay: 100, - }); + const cleanup = []; + if (pageProxy !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: closing page proxy\n"); + await pageProxy.close(); + }); + } + if (browser !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: stopping owned Chrome\n"); + await stopProcess(browser); + }); + } + if (site !== undefined) { + cleanup.push(async () => { + process.stderr.write("Browser verifier cleanup: closing fixture site\n"); + await site.close(); + }); + } + if (profile !== undefined) + cleanup.push(() => + rm(profile, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }), + ); + await closeRuntimeFixtureResources(cleanup, verificationError); } process.stdout.write( `${JSON.stringify({ diff --git a/scripts/verify-real-ghidra-dos.mjs b/scripts/verify-real-ghidra-dos.mjs index 50a014d0a..b212a018d 100644 --- a/scripts/verify-real-ghidra-dos.mjs +++ b/scripts/verify-real-ghidra-dos.mjs @@ -24,7 +24,11 @@ import { } from "../dist/domain/hopperValues.js"; import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; import { buildDosMzFixture } from "../tests/conformance/ghidra/dos-mz-fixture.mjs"; -import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { + assertVerifierCleanupComplete, + createVerifierRun, + completeVerifierRun, +} from "./lib/verifier-run.mjs"; import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundaries.mjs"; // This lane intentionally has no game paths, binary seeds or compiler dependency. @@ -479,12 +483,7 @@ try { } assert.ok(report, "No DOS proof report was produced"); const verifierRun = await completeVerifierRun(run); -if (verifierRun.process_lineage.status === "verified") - assert.deepEqual( - verifierRun.process_lineage.descendants, - [], - "Owned descendants survived verifier cleanup", - ); +assertVerifierCleanupComplete(verifierRun.process_lineage); process.stdout.write( `${JSON.stringify({ verifier_run: verifierRun, ...report, cleanup: "complete" })}\n`, ); diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 5123d3400..c05952118 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -2,8 +2,16 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, readFile, writeFile, readdir } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { isAbsolute, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -51,11 +59,32 @@ const tool = (name, args, cwd) => const sha256 = (value) => createHash("sha256").update(value).digest("hex"); const run = createVerifierRun(); const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-verifier-" }); +// Keep provider scratch outside the leased private input root. On Windows the +// root's retained DELETE handle blocks child temp creation beneath that root. +let childTempRoot; +try { + childTempRoot = await mkdtemp(join(tmpdir(), "rea-wabt-child-temp-")); +} catch (cause) { + try { + await root.close(); + } catch (cleanupCause) { + throw new AggregateError( + [cause, cleanupCause], + "Could not allocate verifier temp space or release its private root.", + ); + } + throw cause; +} const client = new Client({ name: "wabt-artifact-verifier", version: "1" }); const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], - env: { ...environment, TMPDIR: root.path, TMP: root.path, TEMP: root.path }, + env: { + ...environment, + TMPDIR: childTempRoot, + TMP: childTempRoot, + TEMP: childTempRoot, + }, stderr: "pipe", }); let cases = 0; @@ -95,9 +124,9 @@ try { { env: { ...environment, - TMPDIR: root.path, - TMP: root.path, - TEMP: root.path, + TMPDIR: childTempRoot, + TMP: childTempRoot, + TEMP: childTempRoot, }, timeout: 120_000, maxBuffer: 64 * 1024 * 1024, @@ -388,13 +417,26 @@ try { } try { assert.ok( - !(await readdir(root.path)).some((name) => name.startsWith("rea-wabt-")), + !(await readdir(childTempRoot)).some((name) => + name.startsWith("rea-wabt-"), + ), "Provider workspace leaked", ); +} catch (cause) { + failures.push(cause); +} +// Each directory is an independent owner. Attempt every cleanup even if a +// residue assertion or another owner's close fails, retaining every error. +try { await root.close(); } catch (cause) { failures.push(cause); } +try { + await rm(childTempRoot, { recursive: true, force: true }); +} catch (cause) { + failures.push(cause); +} if (failures.length > 0) throw new AggregateError(failures, "WABT real-tool verification failed."); console.log( diff --git a/tests/boundary/browser/browserProcessStartup.test.ts b/tests/boundary/browser/browserProcessStartup.test.ts index b1cfaeafe..3bf88d102 100644 --- a/tests/boundary/browser/browserProcessStartup.test.ts +++ b/tests/boundary/browser/browserProcessStartup.test.ts @@ -14,8 +14,31 @@ import { BrowserStartupError, waitForBrowserDevtoolsPort, } from "../../../src/browser/BrowserProcessStartup.js"; +import { assertOrdinaryWindowsVerifierCaller } from "../../../scripts/lib/windows-ordinary-user-verifier.mjs"; describe("browser process startup", () => { + it("requires a positively observed ordinary Windows verifier caller", () => { + expect(() => + assertOrdinaryWindowsVerifierCaller({ + administrator: false, + elevated: false, + integrityLevel: 8192, + }), + ).not.toThrow(); + + for (const token of [ + { administrator: true, elevated: false }, + { administrator: false, elevated: true }, + { administrator: false }, + { administrator: false, elevated: null }, + null, + ]) { + expect(() => assertOrdinaryWindowsVerifierCaller(token)).toThrow( + /ordinary, non-elevated user token/u, + ); + } + }); + it("returns a delayed valid DevToolsActivePort", async ({ processes }) => { const root = await temporaryRoot(); const portPath = join(root, "DevToolsActivePort"); diff --git a/tests/boundary/browser/browserVerifierFixture.test.ts b/tests/boundary/browser/browserVerifierFixture.test.ts new file mode 100644 index 000000000..e171d14f8 --- /dev/null +++ b/tests/boundary/browser/browserVerifierFixture.test.ts @@ -0,0 +1,30 @@ +import { createConnection } from "node:net"; +import { it, expect } from "vitest"; +import { startBrowserVerifierSite } from "../../../scripts/fixtures/browser-verifier-site.mjs"; + +it("closes an accepted HTTP socket that has not sent request headers", async () => { + const site = await startBrowserVerifierSite(); + const { port } = new URL(site.origin); + const socket = createConnection(Number(port), "127.0.0.1"); + let close: Promise | undefined; + let timeout: ReturnType | undefined; + try { + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const closing = site.close(); + close = closing; + const closedBeforeDeadline = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), 1_000); + }), + ]); + expect(closedBeforeDeadline).toBe(true); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + socket.destroy(); + await (close ?? site.close()); + } +}); diff --git a/tests/boundary/filesystem/packageClientBackup.test.ts b/tests/boundary/filesystem/packageClientBackup.test.ts index cd57fda10..8294c76c8 100644 --- a/tests/boundary/filesystem/packageClientBackup.test.ts +++ b/tests/boundary/filesystem/packageClientBackup.test.ts @@ -2,9 +2,27 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { assertClientConfig } from "../../../scripts/verify-package-setup.mjs"; +import { + assertClientConfig, + terminalModesRestored, +} from "../../../scripts/verify-package-setup.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it("requires positive canonical-input and echo terminal mode tokens", () => { + expect(terminalModesRestored("speed 38400 baud; icanon echo isig")).toBe( + true, + ); + expect(terminalModesRestored("speed 38400 baud; -icanon -echo isig")).toBe( + false, + ); + expect(terminalModesRestored("speed 38400 baud; -icanon echo isig")).toBe( + false, + ); + expect(terminalModesRestored("speed 38400 baud; icanon -echo isig")).toBe( + false, + ); +}); + it("rejects a backup replaced with the post-setup client configuration", async () => { const directory = await createTestTempDirectory("rea-client-backup-"); const path = join(directory, "client.json"); diff --git a/tests/boundary/process/verifierRun.test.ts b/tests/boundary/process/verifierRun.test.ts new file mode 100644 index 000000000..af8f5447e --- /dev/null +++ b/tests/boundary/process/verifierRun.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; + +import { assertVerifierCleanupComplete } from "../../../scripts/lib/verifier-run.mjs"; + +it("requires verified empty lineage before claiming verifier cleanup", () => { + expect(() => + assertVerifierCleanupComplete({ + status: "unavailable", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: null, + reason: "snapshot unavailable", + }), + ).toThrow("Verifier cleanup could not be verified: snapshot unavailable"); + + expect(() => + assertVerifierCleanupComplete({ + status: "verified", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: 100, + descendants: [{ pid: 101, parent_pid: 100, process_group_id: 100 }], + }), + ).toThrow("Owned descendants survived verifier cleanup"); + + expect(() => + assertVerifierCleanupComplete({ + status: "verified", + observed_at: "2026-10-14T00:00:00.000Z", + launcher_pid: 100, + launcher_parent_pid: 1, + process_group_id: 100, + descendants: [], + }), + ).not.toThrow(); +}); From e572c38dcf6ae72176319baef000784ff7d15e2f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:47:58 +0800 Subject: [PATCH 13/18] docs: repair analysis guide links and document Linux process prerequisites --- README.md | 8 +++++--- docs/installation.md | 5 +++++ docs/readme/README_ar.md | 6 +++--- docs/readme/README_de.md | 6 +++--- docs/readme/README_el.md | 6 +++--- docs/readme/README_es.md | 6 +++--- docs/readme/README_fa.md | 6 +++--- docs/readme/README_fr.md | 6 +++--- docs/readme/README_id.md | 6 +++--- docs/readme/README_it.md | 6 +++--- docs/readme/README_ja.md | 6 +++--- docs/readme/README_ko.md | 6 +++--- docs/readme/README_pl.md | 6 +++--- docs/readme/README_pt-BR.md | 6 +++--- docs/readme/README_ru.md | 6 +++--- docs/readme/README_th.md | 6 +++--- docs/readme/README_tr.md | 6 +++--- docs/readme/README_uk.md | 6 +++--- docs/readme/README_vi.md | 6 +++--- docs/readme/README_zh-TW.md | 6 +++--- docs/readme/README_zh.md | 6 +++--- 21 files changed, 67 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 0088640d0..16a3bc019 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,12 @@ CLI commands use the same workflows. REA requires Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+, plus npm. Additional tools and host support depend on the target: +Linux supervised analysis also requires a procps-compatible `ps` on REA's +`PATH`; distributions provide it through `procps` or `procps-ng`. | Target | What REA returns | Requirements and guide | | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) | +| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) | | Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) | | Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required | | Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) | @@ -167,8 +169,8 @@ Additional tools and host support depend on the target: | EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) | | SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits | | Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools on Linux x64; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) | -| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://rea.tools/guides/javascript/) | -| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) | +| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](docs/javascript-application-workflows.md) | +| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](docs/browser-observation.md) | | Saved network captures | Requests, responses, exposed payloads and source locations | HAR or Fiddler Everywhere SAZ; mitmdump on Linux for native mitmproxy; [capture guide](docs/web-network-captures.md) | | .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) | | Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) | diff --git a/docs/installation.md b/docs/installation.md index bb6a2dd5c..c085ae89e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,6 +28,11 @@ For the latest published release, use `npx rea-agents@latest setup`. [Package-runner and global-installation options](#package-runners-and-global-installation) cover explicit versions, Bun, runtime requirements, and the curl wrapper. +On Linux, supervised analysis requires a procps-compatible `ps` on REA's +`PATH` so REA can inspect process ownership before launching a child. Install +the `procps` or `procps-ng` package provided by your distribution. This +prerequisite also applies to JavaScript and Electron workflows. + ## Review setup changes `rea setup` first offers the supported agents in a multi-select. Existing REA diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 64e963c91..5ece019cc 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -137,13 +137,13 @@ npx rea-agents@latest setup | الهدف | ما يعيده REA | المتطلبات والدليل | | ------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) | +| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](../native-investigation.md) | | بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) | | شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) | -| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) | -| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) | +| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](../javascript-application-workflows.md) | +| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](../browser-observation.md) | | تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](../web-network-captures.md) | | التقاط الحزم | نقاط النهاية وDNS وTLS SNI وتوزيع البروتوكولات وطلبات HTTP | tshark وcapinfos يوفّرهما المُستدعي؛ [دليل pcap](../pcap-analysis.md) | | تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](../managed-code-analysis.md) | diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index aee634f06..b8f609b0a 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -137,13 +137,13 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Ziel | Was REA zurückgibt | Voraussetzungen und Anleitung | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) | +| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](../native-investigation.md) | | Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) | | EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) | -| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) | -| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) | +| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](../javascript-application-workflows.md) | +| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](../browser-observation.md) | | Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](../web-network-captures.md) | | Paketerfassungen | Endpunkte, DNS, TLS SNI, Protokollverteilung und HTTP-Anfragen | Vom Aufrufer bereitgestellte tshark- und capinfos-Binaries; [pcap-Leitfaden](../pcap-analysis.md) | | .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](../managed-code-analysis.md) | diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 699a3ebdc..28d13308a 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -141,7 +141,7 @@ npx rea-agents@latest setup | Στόχος | Τι επιστρέφει το REA | Απαιτήσεις και οδηγός | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Δυαδικά αρχεία με κώδικα μηχανής | Ψευδοκώδικα, κώδικα assembly, συμβολοσειρές, σύμβολα, κλήσεις και αναφορές | Hopper, Ghidra ή IDA· [ανάλυση κώδικα μηχανής](https://rea.tools/guides/native/) | +| Δυαδικά αρχεία με κώδικα μηχανής | Ψευδοκώδικα, κώδικα assembly, συμβολοσειρές, σύμβολα, κλήσεις και αναφορές | Hopper, Ghidra ή IDA· [ανάλυση κώδικα μηχανής](../native-investigation.md) | | Δομή ELF χωρίς εκτέλεση | Ενότητες, τμήματα, αρχικά σύμβολα/εγγραφές μετεγκατάστασης και ενδείξεις πιθανών μέτρων προστασίας από στατική ανάλυση | pwntools που παρέχει ο χρήστης σε Linux x64· [διαγνωστικά δυαδικών αρχείων](../binary-diagnostics.md) | | Δυαδικά αρχεία Go | Ενσωματωμένη έκδοση μεταγλωττιστή, modules, αντικαταστάσεις και ρυθμίσεις μεταγλώττισης | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· δεν απαιτείται εγκατάσταση του Go | | Δυαδικά αρχεία Rust | Ενσωματωμένα ονόματα, εκδόσεις, πηγές και σχέσεις εξάρτησης των crates από το cargo-auditable | Ενσωματωμένη στατική εξέταση ELF/PE/thin Mach-O· [δυαδικά αρχεία Rust](../rust-binaries.md) | @@ -149,8 +149,8 @@ npx rea-agents@latest setup | Bytecode EVM | Selectors επιλογής συνάρτησης, μετατοπίσεις σε bytes, εκτιμώμενα ορίσματα και δυνατότητα μεταβολής της κατάστασης | Τοπική είσοδος σε ακατέργαστα bytes ή δεκαεξαδική μορφή σε Linux x64· [οδηγός EVM χωρίς εκτέλεση](../evm-bytecode.md) | | Βάσεις δεδομένων SQLite | Πίνακες, στήλες, ευρετήρια, όψεις, triggers και επιλεγμένες γραμμές κανονικών πινάκων | Τοπικό στιγμιότυπο βάσης δεδομένων/WAL· Node.js 24.x >=24.15 ή 26+ με εγγενή όρια SQLite | | Καταγεγραμμένες καταρρεύσεις στο Linux | Ακατέργαστες εγγραφές notes, καταχωρητές/σήματα κάθε καταγεγραμμένου νήματος και προαιρετικές πιθανές αντιστοιχίσεις μνήμης | pwntools που παρέχει ο χρήστης σε Linux x64· προαιρετικά GDB/pwndbg· [καταγεγραμμένες καταρρεύσεις](../recorded-crashes.md) | -| JavaScript / Electron | Modules, εισαγωγές, source maps, διαδρομές, IPC και σχέσεις με πρόσθετα κώδικα μηχανής | Node.js και npm· [ανάλυση εφαρμογών](https://rea.tools/guides/javascript/) | -| Ιστότοποι | Δομή σελίδων, scripts, παρατηρήσεις δικτύου και ζητούμενα στιγμιότυπα οθόνης | Πρόγραμμα περιήγησης της οικογένειας Chrome· [ανάλυση μέσω προγράμματος περιήγησης](https://rea.tools/guides/browser/) | +| JavaScript / Electron | Modules, εισαγωγές, source maps, διαδρομές, IPC και σχέσεις με πρόσθετα κώδικα μηχανής | Node.js και npm· [ανάλυση εφαρμογών](../javascript-application-workflows.md) | +| Ιστότοποι | Δομή σελίδων, scripts, παρατηρήσεις δικτύου και ζητούμενα στιγμιότυπα οθόνης | Πρόγραμμα περιήγησης της οικογένειας Chrome· [ανάλυση μέσω προγράμματος περιήγησης](../browser-observation.md) | | Αποθηκευμένες καταγραφές δικτύου | Αιτήματα, αποκρίσεις, διαθέσιμα δεδομένα φορτίου και θέσεις στην πηγή | HAR ή SAZ του Fiddler Everywhere· mitmdump σε Linux για την εγγενή μορφή mitmproxy· [οδηγός καταγραφών](../web-network-captures.md) | | Assemblies .NET | Μεταδεδομένα, εντολές CIL, δηλωμένες εξαρτήσεις από κώδικα μηχανής και συγκρίσεις builds | Καμία πρόσθετη απαίτηση (μόνο στατική ανάλυση)· [οδηγός διαχειριζόμενου κώδικα](../managed-code-analysis.md) | | APK Android | Δηλώσεις manifest, κλάσεις, απομεταγλωττισμένες μέθοδοι και αναφορές | JADX χωρίς γραφικό περιβάλλον και πλήρες JDK σε Linux/macOS/Windows x64· [οδηγός Android](../android-analysis.md) | diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index 07f2517ea..711044707 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -137,13 +137,13 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Objetivo | Qué devuelve REA | Requisitos y guía | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) | +| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](../native-investigation.md) | | Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) | | Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) | -| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) | -| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) | +| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](../javascript-application-workflows.md) | +| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](../browser-observation.md) | | Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](../web-network-captures.md) | | Capturas de paquetes | Endpoints, DNS, TLS SNI, distribución de protocolos y solicitudes HTTP | tshark y capinfos suministrados por el autor de la llamada; [guía pcap](../pcap-analysis.md) | | Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](../managed-code-analysis.md) | diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index bd129ded1..06b9464f8 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -129,13 +129,13 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | هدف | خروجی REA | پیش‌نیازها و راهنما | | ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) | +| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](../native-investigation.md) | | ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) | | بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) | -| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) | -| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) | +| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](../javascript-application-workflows.md) | +| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](../browser-observation.md) | | داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](../web-network-captures.md) | | اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](../managed-code-analysis.md) | | فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](../android-analysis.md) | diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 255b15a9a..6fc0020d2 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -140,14 +140,14 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Cible | Ce que REA renvoie | Prérequis et guide | | ---------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](https://rea.tools/fr/guides/native/) | +| Binaires natifs | Pseudo-code, assembleur, chaînes, symboles, appels et références | Hopper, Ghidra ou IDA ; [analyse native](../native-investigation.md) | | Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) | | Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` | | Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) | -| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) | -| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) | +| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](../javascript-application-workflows.md) | +| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](../browser-observation.md) | | Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](../web-network-captures.md) | | Captures de paquets | Points de terminaison, DNS, TLS SNI, distribution des protocoles et requêtes HTTP | tshark et capinfos fournis par l'appelant ; [guide pcap](../pcap-analysis.md) | | Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](../managed-code-analysis.md) | diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 713cfc0c0..50dbb4a17 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -143,14 +143,14 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Target | Hasil yang diberikan REA | Persyaratan dan panduan | | -------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/id/guides/native/) | +| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](../native-investigation.md) | | Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) | | Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` | | Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) | -| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) | -| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) | +| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](../javascript-application-workflows.md) | +| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](../browser-observation.md) | | Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](../web-network-captures.md) | | Capturan paket | Endpoint, DNS, TLS SNI, distribusi protokol, dan permintaan HTTP | tshark dan capinfos buatan pemanggil; [panduan pcap](../pcap-analysis.md) | | Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](../managed-code-analysis.md) | diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index d9b73e923..34541e624 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -140,14 +140,14 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Obiettivo | Cosa restituisce REA | Requisiti e guida | | ---------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](https://rea.tools/it/guides/native/) | +| Binari nativi | Pseudocodice, assembly, stringhe, simboli, chiamate e riferimenti | Hopper, Ghidra o IDA; [analisi nativa](../native-investigation.md) | | Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) | | Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` | | Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) | -| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) | -| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) | +| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](../javascript-application-workflows.md) | +| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](../browser-observation.md) | | Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](../web-network-captures.md) | | Captured di pacchetti | Endpoint, DNS, TLS SNI, distribuzione dei protocolli e richieste HTTP | tshark e capinfos forniti dal chiamante; [guida pcap](../pcap-analysis.md) | | Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](../managed-code-analysis.md) | diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index afad1f488..08859d35f 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -137,13 +137,13 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | 対象 | REA が返すもの | 要件とガイド | | -------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) | +| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](../native-investigation.md) | | オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) | | EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) | -| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) | -| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) | +| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](../javascript-application-workflows.md) | +| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](../browser-observation.md) | | 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](../web-network-captures.md) | | パケットキャプチャ | エンドポイント、DNS、TLS SNI、プロトコル分布、HTTPリクエスト | 呼び出し側が用意した tshark と capinfos;[pcapガイド](../pcap-analysis.md) | | .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](../managed-code-analysis.md) | diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index e6affdb19..ebb69557e 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -139,7 +139,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | 대상 | REA가 반환하는 내용 | 요구 사항 및 가이드 | | --------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) | +| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](../native-investigation.md) | | 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) | | Go 바이너리 | 내장된 컴파일러 버전, 모듈, `replace` 지시어, 빌드 설정 | REA 내장 ELF/PE/thin Mach-O 정적 검사; Go 설치 불필요 | | Rust 바이너리 | cargo-auditable이 내장한 크레이트 이름, 버전, 소스, 의존성 관계 | REA 내장 ELF/PE/thin Mach-O 정적 검사; [Rust 바이너리](../rust-binaries.md) | @@ -147,8 +147,8 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) | | SQLite 데이터베이스 | 테이블, 열, 인덱스, 뷰, 트리거, 선택한 일반 테이블의 행 | 로컬 데이터베이스/WAL 스냅샷; Node.js 24.15 이상의 24.x 또는 26 이상(네이티브 SQLite 한도 적용) | | 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) | -| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) | -| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) | +| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](../javascript-application-workflows.md) | +| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](../browser-observation.md) | | 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR 또는 Fiddler Everywhere SAZ; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](../web-network-captures.md) | | .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 별도 도구 불필요(정적 검사만); [관리 코드 가이드](../managed-code-analysis.md) | | Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](../android-analysis.md) | diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index c49c1ec05..1f46edb79 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -137,13 +137,13 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Cel | Co zwraca REA | Wymagania i przewodnik | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) | +| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](../native-investigation.md) | | Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) | | Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) | -| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) | -| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) | +| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](../javascript-application-workflows.md) | +| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](../browser-observation.md) | | Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](../web-network-captures.md) | | Przechwytywanie pakietów | Punkty końcowe, DNS, TLS SNI, dystrybucja protokołów i żądania HTTP | Dostarczone przez wywołującego tshark i capinfos; [przewodnik pcap](../pcap-analysis.md) | | Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](../managed-code-analysis.md) | diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index ec7ecd29c..c5e677924 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -137,13 +137,13 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Alvo | O que o REA retorna | Requisitos e guia | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) | +| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](../native-investigation.md) | | Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) | | Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) | -| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) | -| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) | +| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](../javascript-application-workflows.md) | +| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](../browser-observation.md) | | Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](../web-network-captures.md) | | Capturas de pacotes | Endpoints, DNS, TLS SNI, distribuição de protocolos e requisições HTTP | tshark e capinfos fornecidos pelo chamador; [guia pcap](../pcap-analysis.md) | | Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](../managed-code-analysis.md) | diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 136344e9f..7455ebb0d 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -137,13 +137,13 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Цель | Что возвращает REA | Требования и руководство | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) | +| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](../native-investigation.md) | | Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) | | Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) | -| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) | -| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) | +| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](../javascript-application-workflows.md) | +| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](../browser-observation.md) | | Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](../web-network-captures.md) | | Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](../managed-code-analysis.md) | | Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](../android-analysis.md) | diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index f758ca421..aabc8ac11 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -137,13 +137,13 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | เป้าหมาย | สิ่งที่ REA ส่งกลับ | ข้อกำหนดและคู่มือ | | ---------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) | +| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](../native-investigation.md) | | โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) | | ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) | -| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) | -| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) | +| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](../javascript-application-workflows.md) | +| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](../browser-observation.md) | | ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](../web-network-captures.md) | | การจับแพ็กเก็ต | จุดเชื่อมต่อ, DNS, TLS SNI, การกระจายโปรโตคอล และคำขอ HTTP | tshark และ capinfos ที่ผู้เรียกจัดเตรียม; [คู่มือ pcap](../pcap-analysis.md) | | แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](../managed-code-analysis.md) | diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 5b4de00d5..2eceaa033 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -139,7 +139,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Hedef | REA'nın döndürdükleri | Gereksinimler ve rehber | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](https://rea.tools/tr/guides/native/) | +| Yerel makine kodu içeren ikili dosyalar | Sözde kod, assembly kodu, dizeler, semboller, çağrılar ve referanslar | Hopper, Ghidra veya IDA; [yerel kod analizi](../native-investigation.md) | | Çevrimdışı ELF yapısı | Bölümler, segmentler, özgün semboller/yeniden konumlandırmalar ve statik analizden olası güvenlik önlemleri | Linux x64 üzerinde çağıranın sağladığı pwntools; [ikili dosya tanılama](../binary-diagnostics.md) | | Go ikili dosyaları | Gömülü compiler sürümü, modüller, replacement'lar ve build ayarları | Yerleşik statik ELF/PE/thin Mach-O incelemesi; Go kurulumu gerekmez | | Rust ikili dosyaları | Gömülü cargo-auditable crate adları, sürümleri, kaynakları ve bağımlılık ilişkileri | Yerleşik statik ELF/PE/thin Mach-O incelemesi; [Rust ikili dosyaları](../rust-binaries.md) | @@ -147,8 +147,8 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | EVM bayt kodu | Yönlendirme seçicileri, bayt ofsetleri, çıkarımla belirlenen argümanlar ve durum değiştirilebilirliği | Linux x64 üzerinde yerel ham bayt/onaltılık girdi; [çevrimdışı EVM rehberi](../evm-bytecode.md) | | SQLite veritabanları | Tablolar, sütunlar, index'ler, view'lar, trigger'lar ve seçilen sıradan tablo satırları | Yerel database/WAL snapshot'ı; native SQLite sınırlarıyla Node.js 24.x >=24.15 veya 26+ | | Kaydedilmiş Linux çökmeleri | Ham note kayıtları, kaydedilen her iş parçacığının yazmaçları/sinyalleri ve isteğe bağlı eşleme adayları | Linux x64 üzerinde çağıranın sağladığı pwntools; isteğe bağlı GDB/pwndbg; [kaydedilmiş çökmeler](../recorded-crashes.md) | -| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](https://rea.tools/tr/guides/javascript/) | -| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](https://rea.tools/tr/guides/browser/) | +| JavaScript / Electron | Modüller, içe aktarmalar, kaynak haritaları, rotalar, IPC ve yerel eklenti ilişkileri | Node.js ve npm; [uygulama analizi](../javascript-application-workflows.md) | +| Web siteleri | Sayfa yapısı, betikler, ağ gözlemleri ve istenen ekran görüntüleri | Chrome ailesinden bir tarayıcı; [tarayıcı analizi](../browser-observation.md) | | Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR veya Fiddler Everywhere SAZ; Linux'ta native mitmproxy için mitmdump; [ağ yakalama rehberi](../web-network-captures.md) | | .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](../managed-code-analysis.md) | | Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](../android-analysis.md) | diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 3eebfeeab..3aa607d75 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -137,13 +137,13 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Ціль | Що повертає REA | Вимоги й посібник | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) | +| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](../native-investigation.md) | | Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) | | Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) | -| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) | -| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) | +| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](../javascript-application-workflows.md) | +| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](../browser-observation.md) | | Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](../web-network-captures.md) | | Захоплення пакетів | Кінцеві точки, DNS, TLS SNI, розподіл протоколів та HTTP-запити | Надані викликаючою стороною tshark та capinfos; [підручник pcap](../pcap-analysis.md) | | Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](../managed-code-analysis.md) | diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index e5879c872..a07dc1982 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -137,13 +137,13 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Mục tiêu | REA trả về gì | Yêu cầu và hướng dẫn | | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) | +| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](../native-investigation.md) | | Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) | | Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) | -| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) | -| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) | +| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](../javascript-application-workflows.md) | +| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](../browser-observation.md) | | Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](../web-network-captures.md) | | Bắt gói tin | Điểm cuối, DNS, TLS SNI, phân bố giao thức và yêu cầu HTTP | tshark và capinfos do nơi gọi cung cấp; [hướng dẫn pcap](../pcap-analysis.md) | | Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](../managed-code-analysis.md) | diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index 86ad30c82..6d339393c 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -139,15 +139,15 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | 目標 | REA 返回的內容 | 要求與指南 | | --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-TW/guides/native/) | +| 原生二進位檔案 | 偽程式碼、組合語言、字串、符號、呼叫和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | | 離線 ELF 佈局 | 節、段、原始符號/重定位資訊及靜態防護機制候選項 | Linux x64 上由呼叫方提供的 pwntools;[二進位診斷](../binary-diagnostics.md) | | Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go | | WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) | -| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) | -| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) | +| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](../javascript-application-workflows.md) | +| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](../browser-observation.md) | | 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](../web-network-captures.md) | | 封包擷取 | 端點、DNS、TLS SNI、通訊協定分佈和 HTTP 請求 | 呼叫端提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | | .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](../managed-code-analysis.md) | diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index 304b89389..b28d356da 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -139,15 +139,15 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | 目标 | REA 返回的内容 | 要求与指南 | | --------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](https://rea.tools/zh-CN/guides/native/) | +| 原生二进制文件 | 伪代码、汇编、字符串、符号、调用和引用 | Hopper、Ghidra 或 IDA;[原生分析](../native-investigation.md) | | 离线 ELF 布局 | 节、段、原始符号/重定位信息及静态防护机制候选项 | Linux x64 上由调用方提供的 pwntools;[二进制诊断](../binary-diagnostics.md) | | Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go | | WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` | | EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) | | Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) | | 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) | -| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) | -| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) | +| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](../javascript-application-workflows.md) | +| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](../browser-observation.md) | | 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](../web-network-captures.md) | | 数据包捕获 | 端点、DNS、TLS SNI、协议分布和 HTTP 请求 | 调用方提供的 tshark 和 capinfos;[pcap 指南](../pcap-analysis.md) | | .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](../managed-code-analysis.md) | From bffdd91053fb5d076b762cf1b325e8dcb5ab2d8c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:49:49 +0800 Subject: [PATCH 14/18] fix(browser): preserve malformed capture gaps and protocol session identity --- src/browser/CdpCaptureDocuments.test.ts | 249 +++++++++++++++++- src/browser/CdpCaptureDocuments.ts | 149 ++++++++--- src/browser/CdpCaptureDomSvg.ts | 144 +++++++--- src/browser/CdpCaptureStorage.test.ts | 58 ++++ src/browser/CdpCaptureStorageFingerprints.ts | 5 +- src/browser/CdpConnection.ts | 6 +- src/browser/CdpElectronInspection.test.ts | 64 +++++ src/browser/CdpElectronInspection.ts | 10 +- src/browser/CdpScreenshot.ts | 2 +- ...PlaywrightElectronActiveProjection.test.ts | 30 ++- .../PlaywrightElectronActiveProjection.ts | 14 +- .../PlaywrightElectronActiveProvider.test.ts | 81 ++++-- .../PlaywrightElectronActiveProvider.ts | 46 ++-- src/browser/WebSourceMapFetcher.ts | 10 +- src/domain/webCaptureDiff.ts | 16 +- tests/boundary/browser/cdpConnection.test.ts | 1 + .../browser/cdpDomSvgIdentity.test.ts | 2 + .../browser/cdpElectronProvider.test.ts | 59 ++++- .../cdpScreenshotDocumentIdentity.test.ts | 24 ++ tests/boundary/browser/webCaptureDiff.test.ts | 38 +++ .../browser/webSourceMapFetcher.test.ts | 15 ++ tests/fixtures/fakeCdpBrowser.ts | 153 ++++++----- 22 files changed, 966 insertions(+), 210 deletions(-) create mode 100644 src/browser/CdpElectronInspection.test.ts diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index c07dfdbe5..362951605 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -2,13 +2,258 @@ import { describe, expect, it } from "vitest"; import { captureAccessibility, + captureDom, captureFrames, captureResources, } from "./CdpCaptureDocuments.js"; +import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; const origin = "https://app.example.test"; -describe("CDP document capture", () => { +describe("CDP DOM capture", () => { + it("preserves node names and parents when aligned arrays are valid", () => { + const generator = captureDom( + { + strings: [origin + "/app", "#document", "DIV", "SPAN", ""], + documents: [ + { + documentURL: 0, + baseURL: 0, + nodes: { + nodeType: [9, 1, 1], + nodeName: [1, 2, 3], + nodeValue: [4, 4, 4], + parentIndex: [-1, 0, 1], + attributes: [[], [], []], + }, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + expect(captured.value.nodes.map((node) => node.node_name)).toEqual([ + "#document", + "DIV", + "SPAN", + ]); + expect(captured.value.nodes.map((node) => node.parent_index)).toEqual([ + -1, 0, 1, + ]); + expect(captured.value.nodes.map((node) => node.node_value_length)).toEqual([ + 0, 0, 0, + ]); + }); + + it.each(["nodeName", "parentIndex", "nodeValue", "attributes"] as const)( + "preserves DOM fact positions and reports malformed %s entries", + (field) => { + const strings = [ + origin + "/app", + "#document", + "DIV", + "SPAN", + "href", + "title", + "/page", + ]; + const nodeArrays: Record = { + nodeType: [9, 1, 1], + nodeName: [1, 2, 3], + nodeValue: [0, 0, 0], + parentIndex: [-1, 0, 1], + attributes: [[], [], []], + }; + if (field === "nodeName") nodeArrays.nodeName = [1, "malformed", 3]; + if (field === "parentIndex") + nodeArrays.parentIndex = [-1, "malformed", 1]; + if (field === "nodeValue") nodeArrays.nodeValue = [0, 0, "malformed"]; + if (field === "attributes") + nodeArrays.attributes = [[], [4, "malformed", 5, 6], []]; + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings, + documents: [ + { + documentURL: 0, + baseURL: 0, + nodes: nodeArrays, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + expect(captured.value.total).toBe(3); + expect(captured.value.nodes.map((node) => node.node_name)).toEqual( + field === "nodeValue" ? ["#document", "DIV"] : ["#document", "SPAN"], + ); + expect(captured.value.nodes.map((node) => node.index)).toEqual( + field === "nodeValue" ? [0, 1] : [0, 2], + ); + expect(captured.value.nodes.map((node) => node.parent_index)).toEqual( + field === "nodeValue" ? [-1, 0] : [-1, 1], + ); + if (field === "attributes") { + expect( + captured.value.nodes.map((node) => node.attribute_names), + ).toEqual([[], []]); + expect(captured.value.urls).toEqual([]); + } + expect(completeness.snapshot()).toMatchObject({ + unavailable_sections: expect.arrayContaining(["dom"]), + excluded: expect.arrayContaining([ + { section: "dom", reason: "invalid_protocol_value", count: 1 }, + ]), + }); + }, + ); +}); + +describe("CDP DOM string and namespace validity", () => { + it.each([ + { field: "nodeName", index: -1 }, + { field: "nodeName", index: 20 }, + { field: "nodeName", index: 2, malformedString: true }, + { field: "nodeValue", index: -1 }, + { field: "nodeValue", index: 20 }, + { field: "nodeValue", index: 5, malformedString: true }, + { field: "attributeName", index: -1 }, + { field: "attributeName", index: 20 }, + { field: "attributeValue", index: -1 }, + { field: "attributeValue", index: 20 }, + ] as const)( + "omits a node when its $field string index is invalid ($index)", + ({ field, index, malformedString }) => { + const strings: unknown[] = [ + origin + "/app", + "#document", + "DIV", + "SPAN", + "href", + "/good", + ]; + const nodeArrays: Record = { + nodeType: [9, 1, 1], + nodeName: [1, 2, 3], + nodeValue: [0, 0, 0], + parentIndex: [-1, 0, 0], + attributes: [[], [], []], + }; + if (field === "nodeName") nodeArrays.nodeName = [1, index, 3]; + if (field === "nodeValue") nodeArrays.nodeValue = [0, index, 0]; + if (field === "attributeName") + nodeArrays.attributes = [[], [index, 5], []]; + if (field === "attributeValue") + nodeArrays.attributes = [[], [4, index], []]; + if (malformedString === true) strings[index === 2 ? 2 : 5] = 42; + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings, + documents: [ + { + documentURL: 0, + baseURL: 0, + nodes: nodeArrays, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + + expect(captured.value.total).toBe(3); + expect(captured.value.nodes.map((node) => node.index)).toEqual([0, 2]); + expect(captured.value.nodes.map((node) => node.node_name)).toEqual([ + "#document", + "SPAN", + ]); + expect(completeness.snapshot().excluded).toContainEqual({ + section: "dom", + reason: "invalid_protocol_value", + count: 1, + }); + }, + ); + + it("does not infer SVG ancestry through an element with malformed namespace data", () => { + const strings: unknown[] = [ + origin + "/app", + "#document", + "svg", + "a", + "xmlns:xlink", + "http://www.w3.org/1999/xlink", + "xlink:href", + "/svg-link", + ]; + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings, + documents: [ + { + documentURL: 0, + baseURL: 0, + nodes: { + nodeType: [9, 1, 1], + nodeName: [1, 2, 3], + nodeValue: [0, 0, 0], + parentIndex: [-1, 0, 1], + attributes: [[], [4, 99], [6, 7]], + }, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + + expect(captured.value.nodes.map((node) => node.index)).toEqual([0, 2]); + expect(captured.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]); + expect(captured.value.urls).toEqual([]); + expect( + completeness + .snapshot() + .excluded.some( + ({ section, reason }) => + section === "metadata" && reason === "invalid_protocol_value", + ), + ).toBe(true); + }); +}); + +describe("CDP frame capture", () => { it("walks deeply nested frame trees and returns every frame and resource", () => { let frameTree: Record = { frame: { id: "frame-0", url: `${origin}/0` }, @@ -30,7 +275,9 @@ describe("CDP document capture", () => { expect(frames.items).toHaveLength(2_000); expect(resources.items).toHaveLength(2_000); }); +}); +describe("CDP accessibility capture", () => { it("retains full approved accessibility text", () => { const capture = captureAccessibility( [ diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index f95904fc0..da7eb8019 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -174,7 +174,7 @@ export function* captureDom( > { const root = requiredRecord(result); const strings = Array.isArray(root.strings) - ? root.strings.map((value) => cdpStringValue(value) ?? "") + ? root.strings.map(cdpStringValue) : []; const nodes: WebPageInspection["dom"]["nodes"] = []; const urls: WebPageInspection["metadata"]["dom_urls"] = []; @@ -184,44 +184,100 @@ export function* captureDom( let unresolvedSvgNodes = 0; for (const document of recordsValue(root.documents)) { const documentUrl = indexedString(strings, document.documentURL); + if (documentUrl === undefined) { + completeness?.exclude("dom", "invalid_protocol_value"); + continue; + } if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) { completeness?.exclude("dom", exclusionReasonForUrl(documentUrl)); continue; } - const baseUrl = indexedString(strings, document.baseURL) || documentUrl; + const baseUrlValue = + document.baseURL === undefined + ? documentUrl + : indexedString(strings, document.baseURL); + if (baseUrlValue === undefined) { + completeness?.exclude("dom", "invalid_protocol_value"); + continue; + } + const baseUrl = baseUrlValue || documentUrl; const documentNodes = recordValue(document.nodes); if (documentNodes === undefined) continue; - const nodeTypes = numberArray(documentNodes.nodeType); - const nodeNames = numberArray(documentNodes.nodeName); - const nodeValues = numberArray(documentNodes.nodeValue); - const parents = numberArray(documentNodes.parentIndex); - const attributes = Array.isArray(documentNodes.attributes) + const rawNodeTypes = numberArray(documentNodes.nodeType); + const rawNodeNames = numberArray(documentNodes.nodeName); + const rawNodeValues = numberArray(documentNodes.nodeValue); + const rawParents = numberArray(documentNodes.parentIndex); + const rawAttributes = Array.isArray(documentNodes.attributes) ? documentNodes.attributes - : []; + : undefined; + if ( + rawNodeTypes === undefined || + rawNodeNames === undefined || + rawNodeValues === undefined || + rawParents === undefined || + rawAttributes === undefined || + rawNodeNames.length !== rawNodeTypes.length || + rawNodeValues.length !== rawNodeTypes.length || + rawParents.length !== rawNodeTypes.length + ) { + completeness?.exclude("dom", "invalid_protocol_value"); + continue; + } + if (rawAttributes.length > rawNodeTypes.length) + completeness?.exclude( + "dom", + "invalid_protocol_value", + rawAttributes.length - rawNodeTypes.length, + ); const backendNodeIds = Array.isArray(documentNodes.backendNodeId) ? documentNodes.backendNodeId : []; - const baseIndex = nodes.length; + const baseIndex = total; + total += rawNodeTypes.length; const ancestry = createDocumentSvgAncestry({ - nodeTypes, - nodeNames, - parents, + nodeTypes: rawNodeTypes, + nodeNames: rawNodeNames, + parents: rawParents, strings, - attributes, + attributes: rawAttributes, }); - total += nodeTypes.length; const pendingMetadata: PendingDomMetadata[] = []; - for (let index = 0; index < nodeTypes.length; index += 1) { - const attributeIndexes = numberArray(attributes[index]); - const parent = Math.trunc(parents[index] ?? -1); - const nodeIndex = nodes.length; - const nodeName = indexedString(strings, nodeNames[index]); - const attributeNames = uniqueAttributeNames(attributeIndexes, strings); + for (let index = 0; index < rawNodeTypes.length; index += 1) { + const nodeType = rawNodeTypes[index]; + const nodeNameIndex = rawNodeNames[index]; + const nodeValueIndex = rawNodeValues[index]; + const parent = rawParents[index]; + const attributeIndexes = numberArray(rawAttributes[index]); + const nodeName = indexedString(strings, nodeNameIndex); + const nodeValue = indexedString(strings, nodeValueIndex); + if ( + nodeType === undefined || + nodeName === undefined || + nodeValue === undefined || + parent === undefined || + parent < -1 || + parent === index || + parent >= rawNodeTypes.length || + attributeIndexes === undefined || + attributeIndexes.length % 2 !== 0 || + attributeIndexes.some( + (value) => + value === undefined || indexedString(strings, value) === undefined, + ) + ) { + completeness?.exclude("dom", "invalid_protocol_value"); + continue; + } + const attributes = attributeIndexes as readonly number[]; + const nodeIndex = baseIndex + index; + const attributeNames = uniqueAttributeNames(attributes, strings); const svgLink = domSvgLink(ancestry, index); + if (svgLink.xmlDocument === undefined) + completeness?.exclude("metadata", "invalid_protocol_value"); let svgQuery: DomSvgNodeQuery | null | undefined; if ( - !svgLink.xmlDocument && - nodeTypes[index] === 1 && + svgLink.xmlDocument === false && + nodeType === 1 && attributeNames.includes("xlink:href") && !attributeNames.includes("href") ) { @@ -236,16 +292,16 @@ export function* captureDom( nodes.push({ index: nodeIndex, parent_index: parent < 0 ? -1 : baseIndex + parent, - node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)), + node_type: nodeType, node_name: nodeName, - node_value_length: indexedString(strings, nodeValues[index]).length, + node_value_length: nodeValue.length, attribute_names: attributeNames, }); pendingMetadata.push({ svgQuery, options: { strings, - attributes: attributeIndexes, + attributes, // The BASE element itself resolves against the document fallback URL. baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl, documentUrl, @@ -257,7 +313,7 @@ export function* captureDom( }); if ( pendingMetadata.length === DOM_METADATA_BATCH_SIZE || - index === nodeTypes.length - 1 + index === rawNodeTypes.length - 1 ) { const metadata = yield* captureDomMetadata( pendingMetadata, @@ -270,6 +326,14 @@ export function* captureDom( pendingMetadata.length = 0; } } + if (pendingMetadata.length > 0) { + const metadata = yield* captureDomMetadata(pendingMetadata, completeness); + urls.push(...metadata.urls); + agentHints.push(...metadata.agentHints); + excludedUrls += metadata.excludedUrls; + unresolvedSvgNodes += metadata.unresolvedSvgNodes; + pendingMetadata.length = 0; + } } return { total, @@ -450,19 +514,25 @@ const accessibilityStates = ( const axText = (value: unknown): string | null => cdpStringValue(recordValue(value)?.value) ?? null; -const indexedString = (strings: readonly string[], index: unknown): string => { +const indexedString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => { const integer = numberValue(index); - return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? ""); + if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) + return undefined; + return strings[integer]; }; const uniqueAttributeNames = ( attributeIndexes: readonly number[], - strings: readonly string[], + strings: readonly (string | undefined)[], ): string[] => { const names: string[] = []; const seen = new Set(); for (let index = 0; index < attributeIndexes.length; index += 2) { const name = indexedString(strings, attributeIndexes[index]); + if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); names.push(name); @@ -470,16 +540,20 @@ const uniqueAttributeNames = ( return names; }; -const numberArray = (value: unknown): readonly number[] => - Array.isArray(value) - ? value.flatMap((item) => { - const number = numberValue(item); - return number === undefined ? [] : [number]; - }) - : []; +const numberArray = ( + value: unknown, +): readonly (number | undefined)[] | undefined => { + if (!Array.isArray(value)) return undefined; + return value.map((item) => { + const number = numberValue(item); + return number !== undefined && Number.isSafeInteger(number) + ? number + : undefined; + }); +}; interface DomMetadataOptions { - readonly strings: readonly string[]; + readonly strings: readonly (string | undefined)[]; readonly attributes: readonly number[]; readonly baseUrl: string; readonly documentUrl: string; @@ -525,6 +599,7 @@ const domMetadata = ( // Chrome already folds HTML names; SVG/XML names remain case-sensitive. const name = indexedString(strings, attributes[index]); const value = indexedString(strings, attributes[index + 1]); + if (name === undefined || value === undefined) continue; // HTML keeps the first declaration. A later repeat must not move dom_urls. if (!pairs.has(name)) pairs.set(name, value); } diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 4d021766c..2ffd8e31f 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -4,10 +4,10 @@ const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; interface DomSnapshotNodes { - readonly nodeTypes: readonly number[]; - readonly nodeNames: readonly number[]; - readonly parents: readonly number[]; - readonly strings: readonly string[]; + readonly nodeTypes: readonly (number | undefined)[]; + readonly nodeNames: readonly (number | undefined)[]; + readonly parents: readonly (number | undefined)[]; + readonly strings: readonly (string | undefined)[]; readonly attributes: readonly unknown[]; } @@ -17,7 +17,7 @@ interface XmlSvgLink { } interface DocumentSvgAncestry { - readonly xml: boolean; + readonly xml: boolean | undefined; readonly xmlLinks: Map; } @@ -48,50 +48,59 @@ export const capturedSvgNodeKind = ( interface DomSvgLink { readonly svgElement: boolean; - readonly xmlDocument: boolean; + readonly xmlDocument: boolean | undefined; readonly xlinkHref: string | undefined; } -const UNRESOLVED: XmlSvgLink = { - svgElement: false, - xlinkHref: undefined, -}; - -const stringAt = (strings: readonly string[], index: unknown): string => { +const stringAt = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => { const integer = numberValue(index); - return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? ""); + if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) + return undefined; + return strings[integer]; }; -const numberArray = (value: unknown): readonly number[] => +const numberArray = (value: unknown): readonly (number | undefined)[] => Array.isArray(value) - ? value.flatMap((item) => { + ? value.map((item) => { const number = numberValue(item); - return number === undefined ? [] : [number]; + return number !== undefined && Number.isSafeInteger(number) + ? number + : undefined; }) : []; // DOMSnapshot has no namespace URI. HTML documents use uppercase `HTML` and // the parser's local names. XML documents preserve prefixed names and xmlns // declarations, including those on standalone SVG roots. -const xmlDocument = (nodes: DomSnapshotNodes): boolean => { - const documentIndex = nodes.nodeTypes.findIndex( - (nodeType) => Math.trunc(nodeType) === 9, - ); +const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { + const documentIndex = nodes.nodeTypes.findIndex((nodeType) => nodeType === 9); if (documentIndex < 0) return false; + if ( + !validSvgSourceNode(nodes, documentIndex) || + nodes.parents[documentIndex] !== -1 + ) + return undefined; for (let index = 0; index < nodes.nodeTypes.length; index += 1) { - if (Math.trunc(nodes.nodeTypes[index] ?? 0) !== 1) continue; - if (Math.trunc(nodes.parents[index] ?? -1) !== documentIndex) continue; + if (nodes.nodeTypes[index] !== 1) continue; + if (nodes.parents[index] !== documentIndex) continue; const name = stringAt(nodes.strings, nodes.nodeNames[index]); + if (name === undefined || !validSvgSourceNode(nodes, index)) + return undefined; if (name === "HTML") return false; if (name === "html" || name.includes(":")) return true; // Explicit namespaces also identify standalone SVG and other XML roots. // Namespace-free synthetic snapshots retain the legacy HTML behavior. const attributes = numberArray(nodes.attributes[index]); - return attributes.some( - (value, offset) => - offset % 2 === 0 && - xmlnsPrefix(stringAt(nodes.strings, value)) !== undefined, - ); + for (let offset = 0; offset + 1 < attributes.length; offset += 2) { + const value = attributes[offset]; + const attributeName = stringAt(nodes.strings, value); + if (attributeName === undefined) return undefined; + if (xmlnsPrefix(attributeName) !== undefined) return true; + } + return false; } return false; }; @@ -102,7 +111,7 @@ export const createDocumentSvgAncestry = ( const xml = xmlDocument(nodes); return { xml, - xmlLinks: xml ? resolveXmlElements(nodes) : new Map(), + xmlLinks: xml === true ? resolveXmlElements(nodes) : new Map(), }; }; @@ -110,14 +119,28 @@ export const domSvgLink = ( ancestry: DocumentSvgAncestry, index: number, ): DomSvgLink => { - if (!ancestry.xml) { + if (ancestry.xml === false) { return { svgElement: false, xmlDocument: false, xlinkHref: undefined, }; } - const resolved = ancestry.xmlLinks.get(index) ?? UNRESOLVED; + if (ancestry.xml === undefined) { + return { + svgElement: false, + xmlDocument: undefined, + xlinkHref: undefined, + }; + } + const resolved = ancestry.xmlLinks.get(index); + if (resolved === undefined) { + return { + svgElement: false, + xmlDocument: undefined, + xlinkHref: undefined, + }; + } return { svgElement: resolved.svgElement, xmlDocument: true, @@ -143,7 +166,9 @@ const resolveXmlElements = ( const children = new Map(); const pending: XmlWalkStep[] = []; for (let node = 0; node < nodes.nodeNames.length; node += 1) { - const parent = Math.trunc(nodes.parents[node] ?? -1); + const parent = nodes.parents[node]; + if (parent === undefined || parent < -1 || parent >= nodes.nodeNames.length) + continue; if (parent < 0) pending.push({ node }); else if (parent < nodes.nodeNames.length && parent !== node) { const siblings = children.get(parent); @@ -164,10 +189,11 @@ const resolveXmlElements = ( continue; } const { node } = step; + if (!validSvgSourceNode(nodes, node)) continue; const attributes = numberArray(nodes.attributes[node]); const restore = applyDeclarations(bindings, attributes, nodes.strings); const svgElement = - Math.trunc(nodes.nodeTypes[node] ?? 0) === 1 && + nodes.nodeTypes[node] === 1 && elementNamespace( stringAt(nodes.strings, nodes.nodeNames[node]), bindings, @@ -188,24 +214,30 @@ const resolveXmlElements = ( const applyDeclarations = ( bindings: Map, - attributes: readonly number[], - strings: readonly string[], + attributes: readonly (number | undefined)[], + strings: readonly (string | undefined)[], ): readonly NamespaceRestore[] => { const restore: NamespaceRestore[] = []; const seen = new Set(); for (let index = 0; index + 1 < attributes.length; index += 2) { - const prefix = xmlnsPrefix(stringAt(strings, attributes[index])); + const nameIndex = attributes[index]; + const valueIndex = attributes[index + 1]; + if (nameIndex === undefined || valueIndex === undefined) continue; + const name = stringAt(strings, nameIndex); + const value = stringAt(strings, valueIndex); + if (name === undefined || value === undefined) continue; + const prefix = xmlnsPrefix(name); if (prefix === undefined || seen.has(prefix)) continue; seen.add(prefix); restore.push({ prefix, previous: bindings.get(prefix) }); - const value = stringAt(strings, attributes[index + 1]); if (value.length === 0) bindings.delete(prefix); else bindings.set(prefix, value); } return restore; }; -const xmlnsPrefix = (name: string): string | undefined => { +const xmlnsPrefix = (name: string | undefined): string | undefined => { + if (name === undefined) return undefined; if (name === "xmlns") return ""; if (!name.startsWith("xmlns:")) return undefined; const prefix = name.slice("xmlns:".length); @@ -215,9 +247,10 @@ const xmlnsPrefix = (name: string): string | undefined => { }; const elementNamespace = ( - nodeName: string, + nodeName: string | undefined, bindings: ReadonlyMap, ): string => { + if (nodeName === undefined) return ""; const colon = nodeName.indexOf(":"); if (colon < 0) return bindings.get("") ?? ""; const prefix = nodeName.slice(0, colon); @@ -235,13 +268,17 @@ const elementNamespace = ( // First prefixed href bound to the XLink namespace. Duplicate names keep the // earlier value. Plain href is applied by the caller and wins even when empty. const xlinkHrefValue = ( - attributes: readonly number[], - strings: readonly string[], + attributes: readonly (number | undefined)[], + strings: readonly (string | undefined)[], bindings: ReadonlyMap, ): string | undefined => { const seen = new Set(); for (let index = 0; index + 1 < attributes.length; index += 2) { - const name = stringAt(strings, attributes[index]); + const nameIndex = attributes[index]; + const valueIndex = attributes[index + 1]; + if (nameIndex === undefined || valueIndex === undefined) continue; + const name = stringAt(strings, nameIndex); + if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); const colon = name.indexOf(":"); @@ -249,7 +286,30 @@ const xlinkHrefValue = ( const prefix = name.slice(0, colon); const local = name.slice(colon + 1); if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue; - return stringAt(strings, attributes[index + 1]); + return stringAt(strings, valueIndex); } return undefined; }; + +const validSvgSourceNode = ( + nodes: DomSnapshotNodes, + index: number, +): boolean => { + if ( + nodes.nodeTypes[index] === undefined || + stringAt(nodes.strings, nodes.nodeNames[index]) === undefined + ) + return false; + const rawAttributes = nodes.attributes[index]; + if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0) + return false; + const attributes = numberArray(rawAttributes); + return ( + attributes.length === rawAttributes.length && + attributes.every( + (attribute) => + attribute !== undefined && + stringAt(nodes.strings, attribute) !== undefined, + ) + ); +}; diff --git a/src/browser/CdpCaptureStorage.test.ts b/src/browser/CdpCaptureStorage.test.ts index 51bb49a08..72665f1b4 100644 --- a/src/browser/CdpCaptureStorage.test.ts +++ b/src/browser/CdpCaptureStorage.test.ts @@ -82,3 +82,61 @@ it.each([false, true])( expect(value.fingerprints_complete).toBe(!mixed); }, ); + +it("keeps an omitted IndexedDB remote value unknown", async () => { + const replies: Record = { + "Storage.getUsageAndQuota": { usage: 0, quota: 1000 }, + "DOMStorage.getDOMStorageItems": { entries: [] }, + "IndexedDB.requestDatabaseNames": { databaseNames: ["db"] }, + "CacheStorage.requestCacheNames": { caches: [] }, + "Network.getCookies": { cookies: [] }, + "IndexedDB.requestDatabase": { + databaseWithObjectStores: { + name: "db", + objectStores: [ + { + name: "records", + keyPath: { type: "string", string: "id" }, + autoIncrement: false, + indexes: [], + }, + ], + }, + }, + "IndexedDB.requestData": { + objectStoreDataEntries: [ + { + key: { type: "number", value: 1 }, + primaryKey: { type: "number", value: 1 }, + }, + ], + hasMore: false, + }, + }; + const connection = { + async send(method: string): Promise { + if (!(method in replies)) throw new Error(method); + return replies[method]; + }, + } as unknown as CdpConnection; + const { value } = await captureStorage( + { + connection, + sessionId: undefined, + input: inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: ["https://example.test"], + target_id: "page-1", + include_storage_keys: true, + include_storage_fingerprints: true, + }), + }, + "https://example.test/app", + [], + ); + const record = value.content_fingerprints.find( + (item) => item.scope === "indexed_db_record", + ); + expect(record).toMatchObject({ value_sha256: null, complete: false }); + expect(value.fingerprints_complete).toBe(false); +}); diff --git a/src/browser/CdpCaptureStorageFingerprints.ts b/src/browser/CdpCaptureStorageFingerprints.ts index ec7a06850..9f8f80c52 100644 --- a/src/browser/CdpCaptureStorageFingerprints.ts +++ b/src/browser/CdpCaptureStorageFingerprints.ts @@ -301,7 +301,10 @@ const addIndexedDbRecords = async ( for (const entry of entries) { const key = stableValue(entry.key); const primaryKey = stableValue(entry.primaryKey); - const value = stableValue(entry.value); + // A missing remote object is an omission, not the JavaScript value + // "undefined". Keep the unavailable digest null and coverage partial. + const value = + entry.value === undefined ? undefined : stableValue(entry.value); addFingerprint(run.state, { scope: "indexed_db_record", identity: { diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 3c7e5e86a..e97005668 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -243,9 +243,9 @@ export class CdpConnection { const hasResult = "result" in message; const hasError = "error" in message; if ( - ("sessionId" in message && - (typeof message.sessionId !== "string" || - message.sessionId !== pending.sessionId)) || + (pending.sessionId === undefined + ? "sessionId" in message + : message.sessionId !== pending.sessionId) || hasResult === hasError ) { this.#failPending("protocol_error"); diff --git a/src/browser/CdpElectronInspection.test.ts b/src/browser/CdpElectronInspection.test.ts new file mode 100644 index 000000000..925b10e9f --- /dev/null +++ b/src/browser/CdpElectronInspection.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; +import { captureFrames } from "./CdpElectronInspection.js"; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("Electron frame capture", () => { + it.each([7, undefined])( + "classifies a malformed child frame ID as unavailable: %s", + async (childId) => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-frame-")); + temporary.push(root); + const main = join(root, "index.html"); + const child = join(root, "child.html"); + await Promise.all([writeFile(main, "main"), writeFile(child, "child")]); + const mainUrl = pathToFileURL(main).href; + const childUrl = pathToFileURL(child).href; + const completeness = new CdpCaptureCompleteness(); + + const frames = await captureFrames( + { + frameTree: { + frame: { id: "frame-main", url: mainUrl }, + childFrames: [ + { + frame: { + ...(childId === undefined ? {} : { id: childId }), + url: childUrl, + }, + }, + ], + }, + }, + completeness, + ); + + expect(frames.map((frame) => frame.frame_id)).toEqual(["frame-main"]); + expect(completeness.snapshot()).toMatchObject({ + unavailable_sections: ["frames"], + excluded: [ + { section: "frames", reason: "invalid_protocol_value", count: 1 }, + ], + }); + expect(completeness.snapshot().policy_filtered_sections).not.toContain( + "frames", + ); + expect(completeness.snapshot().conditions).toContain("attach_limited"); + }, + ); +}); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index e1eb616f9..cf6613f77 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -232,7 +232,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { return undefined; }; -const captureFrames = async ( +export const captureFrames = async ( result: unknown, completeness: CdpCaptureCompleteness, ): Promise => { @@ -245,8 +245,12 @@ const captureFrames = async ( )) { const frame = recordValue(tree.frame); const frameId = cdpStringValue(frame?.id); - const path = await authorizedElectronFile(cdpStringValue(frame?.url) ?? ""); - if (frameId === undefined || path === undefined) { + if (frame === undefined || frameId === undefined) { + completeness.exclude("frames", "invalid_protocol_value"); + continue; + } + const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? ""); + if (path === undefined) { completeness.exclude("frames", "out_of_target_scope"); continue; } diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index b29ab121d..e4093479b 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -94,7 +94,7 @@ export const captureCdpScreenshot = async ( const bytes = encoded === undefined ? undefined : decodeCanonicalBase64(encoded); if (bytes === undefined) - throw new BrowserObservationError("inspect_web_page", "payload_limit"); + throw new BrowserObservationError("inspect_web_page", "protocol_error"); const dimensions = readPngHeaderDimensions(bytes); if (dimensions === undefined) throw new BrowserObservationError("inspect_web_page", "protocol_error"); diff --git a/src/browser/PlaywrightElectronActiveProjection.test.ts b/src/browser/PlaywrightElectronActiveProjection.test.ts index baec9b219..09a5b6910 100644 --- a/src/browser/PlaywrightElectronActiveProjection.test.ts +++ b/src/browser/PlaywrightElectronActiveProjection.test.ts @@ -40,12 +40,15 @@ it("projects hook retention and dropped event families through the result schema estimated_retained_bytes: 1_024, event_serialized_byte_upper_bound: 512, retained: 1, - dropped: 1, + dropped: 2, dropped_ipc: 0, - dropped_runtime: 1, - dropped_event_families: [{ family: "renderer-ipc", count: 1 }], + dropped_runtime: 2, + dropped_event_families: [ + { family: "renderer-ipc", count: 1 }, + { family: "preload-configuration", count: 1 }, + ], dropped_event_roles: [{ role: "renderer", count: 1 }], - observed: 2, + observed: 3, observed_ipc: 1, observed_runtime: 1, hook_error: false, @@ -58,18 +61,29 @@ it("projects hook retention and dropped event families through the result schema estimated_retained_bytes: 1_024, event_serialized_byte_upper_bound: 512, retained: 1, - dropped: 1, - dropped_event_families: [{ family: "renderer-ipc", count: 1 }], + dropped: 2, + dropped_event_families: [ + { family: "renderer-ipc", count: 1 }, + { family: "preload-configuration", count: 1 }, + ], dropped_event_roles: [{ role: "renderer", count: 1 }], }); expect(result.ipc).toMatchObject({ observed: 1, dropped: 0 }); - expect(result.timeline).toMatchObject({ observed: 2, dropped: 1 }); + expect(result.timeline).toMatchObject({ observed: 3, dropped: 2 }); expect(result.coverage.observed_event_families).toContain("renderer-ipc"); expect(result.coverage.unavailable_event_families).not.toContain( "renderer-ipc", ); expect(result.coverage.observed_roles).toContain("renderer"); - expect(result.coverage.unavailable_event_families).toContain("preload"); + expect(result.coverage.observed_event_families).toContain( + "preload-configuration", + ); + expect(result.coverage.unavailable_event_families).not.toContain( + "preload-configuration", + ); + expect(result.limitations).not.toContain( + "Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.", + ); }); it("keeps renderer coverage unavailable when only the main process was observed", () => { diff --git a/src/browser/PlaywrightElectronActiveProjection.ts b/src/browser/PlaywrightElectronActiveProjection.ts index e7b79d633..71f5581b2 100644 --- a/src/browser/PlaywrightElectronActiveProjection.ts +++ b/src/browser/PlaywrightElectronActiveProjection.ts @@ -113,6 +113,18 @@ const createCoverage = (hookSnapshot: ElectronHookSnapshot) => { } as const; }; +const hasObservedPreloadConfiguration = ( + hookSnapshot: ElectronHookSnapshot, +): boolean => + hookSnapshot.events.some( + ({ kind, process_type }) => + kind === "preload" && + (process_type === "main" || process_type === "browser"), + ) || + hookSnapshot.dropped_event_families.some( + ({ family, count }) => family === "preload-configuration" && count > 0, + ); + /** Project one provider hook snapshot into the canonical public result. */ export const projectElectronActiveCapture = ( paths: ElectronCapturePaths, @@ -166,7 +178,7 @@ export const projectElectronActiveCapture = ( "IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.", "The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.", "The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.", - ...(!hookSnapshot.events.some(({ kind }) => kind === "preload") + ...(!hasObservedPreloadConfiguration(hookSnapshot) ? [ "Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.", ] diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 50f9905d5..9bdaaf3cd 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -73,6 +73,23 @@ const ownership = { expectedParentPid: 1, } satisfies OwnedProcessGroup; +const emptyHookSnapshot = () => ({ + events: [], + retention_budget_bytes: 0, + estimated_retained_bytes: 0, + event_serialized_byte_upper_bound: 0, + retained: 0, + dropped: 0, + dropped_ipc: 0, + dropped_runtime: 0, + dropped_event_families: [], + dropped_event_roles: [], + observed: 0, + observed_ipc: 0, + observed_runtime: 0, + hook_error: false, +}); + it("does not signal a Windows Electron PID when lineage is unavailable", async () => { const terminateTree = vi.fn(async () => ({ cleaned: true as const, @@ -138,27 +155,11 @@ it("retains the partial capture without taskkill when Windows lineage is unavail const terminateTree = vi .spyOn(processOwnership, "cleanupWindowsProcessTree") .mockResolvedValue({ cleaned: true, signaled: true }); - const hookSnapshot = { - events: [], - retention_budget_bytes: 0, - estimated_retained_bytes: 0, - event_serialized_byte_upper_bound: 0, - retained: 0, - dropped: 0, - dropped_ipc: 0, - dropped_runtime: 0, - dropped_event_families: [], - dropped_event_roles: [], - observed: 0, - observed_ipc: 0, - observed_runtime: 0, - hook_error: false, - }; vi.spyOn(electronActions, "readApplicationState").mockResolvedValue({ windows: [], metrics: [], electronVersion: "44.0.0", - hookSnapshot, + hookSnapshot: emptyHookSnapshot(), }); const application = { process: () => ({ pid: 424242 }), @@ -206,3 +207,49 @@ it("retains the partial capture without taskkill when Windows lineage is unavail Object.defineProperty(process, "platform", originalPlatform); } }); + +it("retains the initial snapshot when the final state read fails", async () => { + const initialState = { + windows: [], + metrics: [], + electronVersion: "44.0.0", + hookSnapshot: emptyHookSnapshot(), + }; + vi.spyOn(electronActions, "readApplicationState") + .mockResolvedValueOnce(initialState) + .mockRejectedValueOnce(new Error("final renderer state unavailable")); + const application = { + process: () => ({ pid: 424242 }), + close: vi.fn(async () => undefined), + windows: () => [], + }; + const provider = new PlaywrightElectronActiveProvider( + {}, + async () => + application as unknown as Awaited< + ReturnType + >, + ); + + const result = await provider.capture({ + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + actions: [], + }), + application_root: dirname(process.execPath), + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected final state capture to fail"); + const partialObservation = result.error.partialObservation; + expect(partialObservation).toMatchObject({ + kind: "electron-active-observation", + capture: { + application: { electron_version: "44.0.0" }, + limitations: expect.arrayContaining([ + expect.stringContaining("initial pre-action snapshot"), + ]), + }, + }); +}); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 816f86489..68c851352 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -145,11 +145,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ) : ok(capture); } catch (cause: unknown) { - if ( - options.signal?.aborted && - paths !== undefined && - initialState !== undefined - ) { + if (paths !== undefined && initialState !== undefined) { const retainedCapture = projectElectronActiveCapture( paths, actions, @@ -159,25 +155,33 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ...retainedCapture, limitations: [ ...retainedCapture.limitations, - `Final Electron state was unavailable after cancellation: ${cause instanceof Error ? cause.message : String(cause)}. Windows, metrics, IPC, and timeline are the snapshot collected before actions and may omit their effects.`, + `Capture stopped after the initial pre-action snapshot: ${cause instanceof Error ? cause.message : String(cause)}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.`, ], }; } - outcome = err( - options.signal?.aborted === true - ? new BrowserObservationError(OPERATION, "cancelled", { - cause, - ...(capture === undefined - ? {} - : { - partialObservation: { - kind: "electron-active-observation" as const, - capture, - }, - }), - }) - : providerError(cause), - ); + if (options.signal?.aborted === true) + outcome = err( + new BrowserObservationError(OPERATION, "cancelled", { + cause, + ...(capture === undefined + ? {} + : { + partialObservation: { + kind: "electron-active-observation" as const, + capture, + }, + }), + }), + ); + else { + const error = providerError(cause); + if (capture !== undefined) + error.retainPartialObservation({ + kind: "electron-active-observation", + capture, + }); + outcome = err(error); + } } if (application !== undefined) { if (ownership !== undefined) { diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 4ab805f43..0148cb9a2 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -11,6 +11,10 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js"; import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js"; import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; +import { + isTypeOnlyExportDeclaration, + isTypeOnlyImportDeclaration, +} from "../domain/javascript/javascriptModuleDeclaration.js"; import { stringValue } from "../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { AnalyzeWebBundleInput, @@ -1061,7 +1065,11 @@ const originalDependency = ( t.isExportAllDeclaration(node) || t.isExportNamedDeclaration(node) ) - return node.source === null || node.source === undefined + return node.source === null || + node.source === undefined || + (t.isImportDeclaration(node) + ? isTypeOnlyImportDeclaration(node) + : isTypeOnlyExportDeclaration(node)) ? null : { kind: "static_import", specifier: node.source.value }; if (t.isImportExpression(node)) { diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index ba23228b1..39e88e6d6 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -333,6 +333,13 @@ const networkDimension = ( ); const beforeWebSockets = webSocketGroups(before); const afterWebSockets = webSocketGroups(after); + const inventoryComplete = [before, after].every((inspection) => + sectionsComplete(inspection, [ + "network_requests", + "websocket_connections", + "websocket_frames", + ]), + ); const [beforeWebSocketPayload, afterWebSocketPayload] = webSocketPayloadMaps( beforeWebSockets, afterWebSockets, @@ -349,17 +356,12 @@ const networkDimension = ( ...webSocketMetadataMap(afterWebSockets), ...afterWebSocketPayload, ]), - [before, after].every((inspection) => - sectionsComplete(inspection, [ - "network_requests", - "websocket_connections", - "websocket_frames", - ]), - ) && + inventoryComplete && shapesComplete && webSocketPayloadBytesComplete && webSocketShapesComplete, "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.", + inventoryComplete, ); return compared.status === "unchanged" && !responseFieldsComplete ? { diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 993a44e6d..71ef6a245 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -28,6 +28,7 @@ const INVALID_REPLIES = [ }), ], ["invalid result", (id: number) => ({ id, result: [] })], + ["missing session id", (id: number) => ({ id, result: {} })], ["invalid session id", (id: number) => ({ id, result: {}, sessionId: 7 })], [ "foreign session id", diff --git a/tests/boundary/browser/cdpDomSvgIdentity.test.ts b/tests/boundary/browser/cdpDomSvgIdentity.test.ts index 7d42ae336..eeaebf2e7 100644 --- a/tests/boundary/browser/cdpDomSvgIdentity.test.ts +++ b/tests/boundary/browser/cdpDomSvgIdentity.test.ts @@ -254,6 +254,7 @@ describe("CDP SVG query concurrency", () => { browser.emitRawMessage( JSON.stringify({ id: command.id, + sessionId: command.sessionId, result: { node: { backendNodeId: id, @@ -295,6 +296,7 @@ describe("CDP SVG query concurrency", () => { browser.emitRawMessage( JSON.stringify({ id: batch.at(-1)?.id, + sessionId: batch.at(-1)?.sessionId, error: { code: -32_602, message: "Invalid parameters" }, }), ); diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index f11584610..307f4cbbf 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -258,7 +258,62 @@ describe("CdpElectronProvider packaged pages", () => { }); }); -describe("CdpElectronProvider capture", () => { +describe("CdpElectronProvider frame capture", () => { + it.each([7, undefined])( + "reports a malformed child frame ID as unavailable protocol evidence: %s", + async (childId) => { + const root = await electronFixture(); + const main = pathToFileURL(join(root, "index.html")).href; + const child = pathToFileURL(join(root, "child.html")).href; + const browser = await startFakeCdpBrowser({ + electronFileUrl: main, + commandResult: (command) => + command.method === "Page.getFrameTree" + ? { + frameTree: { + frame: { id: "frame-main", url: main }, + childFrames: [ + { + frame: { + ...(childId === undefined ? {} : { id: childId }), + url: child, + }, + }, + ], + }, + } + : undefined, + }); + browsers.push(browser); + const result = await new CdpElectronProvider().inspectPage( + inspectElectronPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "electron-page", + observation_ms: 0, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.frames.map((frame) => frame.frame_id)).toEqual([ + "frame-main", + ]); + expect(result.value.completeness).toMatchObject({ + unavailable_sections: ["frames"], + }); + expect(result.value.completeness.excluded).toContainEqual({ + section: "frames", + reason: "invalid_protocol_value", + count: 1, + }); + expect(result.value.completeness.policy_filtered_sections).not.toContain( + "frames", + ); + expect(result.value.completeness.conditions).toContain("attach_limited"); + }, + ); +}); + +describe("CdpElectronProvider malformed source replies", () => { it.each([{}, { scriptSource: null }, { scriptSource: 123 }])( "rejects malformed Electron source replies instead of inventing source: %j", async (reply) => { @@ -291,7 +346,9 @@ describe("CdpElectronProvider capture", () => { ); }, ); +}); +describe("CdpElectronProvider script source capture", () => { it("retains an explicitly empty Electron producer script", async () => { const root = await electronFixture(); const browser = await startFakeCdpBrowser({ diff --git a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts index b3fcbc592..b76848598 100644 --- a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts +++ b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts @@ -3,6 +3,30 @@ import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; describe("screenshot committed document identity", () => { + it.each([ + ["missing screenshot data", {}], + ["invalid base64 screenshot data", { data: "not base64%%%" }], + ])("reports %s as a protocol error", async (_label, result) => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Page.captureScreenshot" ? result : undefined, + }); + onTestFinished(async () => { + await browser.close(); + }); + const observation = await new CdpBrowserProvider().captureScreenshot( + captureWebScreenshotInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + }), + ); + expect(observation).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + }); + it.each([ { offset: 8, value: 14 }, { offset: 16, value: 0x8000_0000 }, diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 4d372650e..11d992fa4 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -684,6 +684,44 @@ describe("web capture WebSocket payload byte coverage", () => { }); describe("web capture diff", () => { + it("reports request membership changes when selected body shapes are unavailable", async () => { + const browser = await startFakeCdpBrowser(); + browsers.push(browser); + const captured = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!captured.ok) throw captured.error; + const before = structuredClone(captured.value); + const after = structuredClone(captured.value); + const original = before.network.requests[0]; + if (original === undefined) throw new Error("Missing request fixture"); + after.network.requests.push({ + ...structuredClone(original), + request_id: `${original.request_id}-added`, + url: `${browser.allowedOrigin}/added`, + }); + for (const inspection of [before, after]) { + markSectionsComplete(inspection, [ + "network_requests", + "websocket_connections", + "websocket_frames", + ]); + for (const request of inspection.network.requests) + request.body_shapes.status = "unavailable"; + } + + expect(compareNetwork(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [expect.objectContaining({ change: "added" })], + }); + }); + it.each(["status", "mime_type"] as const)( "keeps an independently unavailable HTTP %s unknown", async (field) => { diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index 84ef1506f..782ff8cf0 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -1102,6 +1102,21 @@ describe("source-map dependency coverage of hard-to-parse sources", () => { 'import lib = require("./lib.js");', ["./lib.js"], ], + [ + "whole-statement type-only import", + 'import type { Value } from "./types.js";', + [], + ], + [ + "whole-statement type-only export", + 'export type { Value } from "./types.js";', + [], + ], + [ + "inline type import retains module side effects", + 'import { type Value } from "./types.js";', + ["./types.js"], + ], [ "a local import alias", 'import lib = localAlias;\nimport "./a.js";', diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index aa476a58b..37e59b77b 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -31,44 +31,9 @@ export const startFakeCdpBrowser = async ( let openedConnections = 0; let frameTreeReads = 0; let port = 0; - const http = createServer((request, response) => { - httpRequests.push({ - url: request.url ?? "", - authorization: request.headers.authorization, - cookie: request.headers.cookie, - referer: request.headers.referer, - }); - response.setHeader("content-type", "application/json"); - if (serveSourceMap(request, response, options)) return; - if (request.url === "/json/version") { - response.end( - options.oversizedDiscovery === true - ? " ".repeat(65 * 1_024) - : options.malformedDiscovery === true - ? "{not-json" - : JSON.stringify({ - Browser: "FakeChrome/1.0", - "Protocol-Version": "1.3", - "User-Agent": "FakeChrome", - "V8-Version": "13.0", - "WebKit-Version": "fake-revision", - webSocketDebuggerUrl: - options.invalidBrowserWebSocket === true - ? `ws://localhost:${String(port)}/devtools/invalid/fake` - : options.pageScopedVersionWebSocket === true - ? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}` - : `ws://localhost:${String(port)}/devtools/browser/fake`, - }), - ); - return; - } - if (request.url === "/json/list") { - response.end(JSON.stringify(targets(port, options))); - return; - } - response.statusCode = 404; - response.end("{}"); - }); + const http = createServer((request, response) => + respondToHttpRequest(request, response, httpRequests, options, () => port), + ); const webSockets = new WebSocketServer({ noServer: true }); http.on("upgrade", (request, socket, head) => { if ( @@ -94,7 +59,7 @@ export const startFakeCdpBrowser = async ( commands.push(command); const commandError = options.commandError?.(command); if (commandError !== undefined) { - socket.send(JSON.stringify({ id: command.id, error: commandError })); + sendCommandReply(socket, command, { error: commandError }); return; } if (options.closeOnMethod === command.method) { @@ -107,12 +72,9 @@ export const startFakeCdpBrowser = async ( return; } if (options.unsupportedMethods?.includes(command.method) === true) { - socket.send( - JSON.stringify({ - id: command.id, - error: { code: -32_601, message: "Method not found" }, - }), - ); + sendCommandReply(socket, command, { + error: { code: -32_601, message: "Method not found" }, + }); return; } if ( @@ -120,12 +82,9 @@ export const startFakeCdpBrowser = async ( (command.method === "Target.attachToTarget" || command.sessionId !== undefined) ) { - socket.send( - JSON.stringify({ - id: command.id, - error: { code: -32_600, message: "Direct page socket expected" }, - }), - ); + sendCommandReply(socket, command, { + error: { code: -32_600, message: "Direct page socket expected" }, + }); return; } if (command.method === "Page.getFrameTree") frameTreeReads += 1; @@ -164,6 +123,57 @@ export const startFakeCdpBrowser = async ( }; }; +const respondToHttpRequest = ( + request: IncomingMessage, + response: ServerResponse, + requests: { + url: string; + authorization: string | undefined; + cookie: string | undefined; + referer: string | undefined; + }[], + options: FakeOptions, + port: () => number, +): void => { + requests.push({ + url: request.url ?? "", + authorization: request.headers.authorization, + cookie: request.headers.cookie, + referer: request.headers.referer, + }); + response.setHeader("content-type", "application/json"); + const portNumber = port(); + if (serveSourceMap(request, response, options)) return; + if (request.url === "/json/version") { + response.end( + options.oversizedDiscovery === true + ? " ".repeat(65 * 1_024) + : options.malformedDiscovery === true + ? "{not-json" + : JSON.stringify({ + Browser: "FakeChrome/1.0", + "Protocol-Version": "1.3", + "User-Agent": "FakeChrome", + "V8-Version": "13.0", + "WebKit-Version": "fake-revision", + webSocketDebuggerUrl: + options.invalidBrowserWebSocket === true + ? `ws://localhost:${String(portNumber)}/devtools/invalid/fake` + : options.pageScopedVersionWebSocket === true + ? `ws://localhost:${String(portNumber)}/devtools/page/${versionTargetId(options)}` + : `ws://localhost:${String(portNumber)}/devtools/browser/fake`, + }), + ); + return; + } + if (request.url === "/json/list") { + response.end(JSON.stringify(targets(portNumber, options))); + return; + } + response.statusCode = 404; + response.end("{}"); +}; + const boundPort = ( address: string | null | { readonly port: number }, ): number => { @@ -172,6 +182,25 @@ const boundPort = ( return address.port; }; +/** Every ordinary reply uses the request's flattened target session. */ +const sendCommandReply = ( + socket: WebSocket, + command: FakeCdpCommand, + payload: + | { readonly result: unknown } + | { readonly error: { readonly code: number; readonly message: string } }, +): void => { + socket.send( + JSON.stringify({ + id: command.id, + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + ...payload, + }), + ); +}; + /** Emit a configured producer reply and its associated events. */ const replyToCommand = ( socket: WebSocket, @@ -187,24 +216,16 @@ const replyToCommand = ( frameTreeReads, ) ?? resultFor(command, port, options, frameTreeReads); if (result === undefined) { - socket.send( - JSON.stringify({ - id: command.id, - error: { - code: -32_601, - message: `Method not found: ${command.method}`, - }, - }), - ); + sendCommandReply(socket, command, { + error: { + code: -32_601, + message: `Method not found: ${command.method}`, + }, + }); return; } const reply = () => { - socket.send( - JSON.stringify({ - id: command.id, - result, - }), - ); + sendCommandReply(socket, command, { result }); if (options.malformedEventOnMethod === command.method) socket.send("{not-json"); if (options.malformedEventShapeOnMethod === command.method) From e8d22ec9eb6341a51ef9b09fc7b8e44a95fc3fd3 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:54:58 +0800 Subject: [PATCH 15/18] fix(browser): carry unknown XML ancestry through metadata contracts --- src/browser/CdpCaptureDocuments.test.ts | 14 +++++++++++++- src/browser/CdpCaptureDocuments.ts | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index 362951605..fe67f90aa 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -139,7 +139,19 @@ describe("CDP DOM string and namespace validity", () => { { field: "attributeValue", index: 20 }, ] as const)( "omits a node when its $field string index is invalid ($index)", - ({ field, index, malformedString }) => { + ({ + field, + index, + malformedString, + }: { + readonly field: + | "nodeName" + | "nodeValue" + | "attributeName" + | "attributeValue"; + readonly index: number; + readonly malformedString?: boolean; + }) => { const strings: unknown[] = [ origin + "/app", "#document", diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index da7eb8019..8aeee7e9a 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -561,7 +561,7 @@ interface DomMetadataOptions { readonly nodeName: string; readonly allowedOrigins: ReadonlySet; readonly svgElement: boolean; - readonly xmlDocument: boolean; + readonly xmlDocument: boolean | undefined; readonly xlinkHref: string | undefined; } From 278d7fb284124438eed1da3d248ea2a764dc67bf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:55:08 +0800 Subject: [PATCH 16/18] fix(analysis-view): reject accessor-backed inline evidence before parsing --- .../analysisView/AnalysisViewService.test.ts | 54 ++++++++++------ .../analysisView/AnalysisViewService.ts | 64 ++++++++++++++++++- 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 10258d3f9..b4421f80a 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -24,7 +24,6 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { err, ok } from "../../domain/result.js"; import { EvidenceLedger } from "../investigation/EvidenceLedger.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js"; -import { inspectAnalysisViewInputSchema } from "../../domain/analysisView/analysisView.js"; it.each(["/fixtures/unknown.exe", ""])( "preserves recorded empty native identity fields: path=%j", @@ -433,26 +432,39 @@ it("refuses an object-dense borrowed capture before making a full schema clone", expect(ledger.forAnalysis(parent.evidence_id)).toBe(borrowed); }); -it("borrows inline Evidence until admission without invoking nested accessors", () => { - let accessed = false; - const evidence = { - get normalized_result() { - accessed = true; - throw new Error("Evidence must be admitted before parsing"); - }, - }; - const input = { - source: { kind: "inline", evidence }, - view: { kind: "api-surface" }, - }; - const parsed = inspectAnalysisViewInputSchema.parse(input); - if (parsed.source.kind !== "inline") throw new Error("Expected inline input"); - expect(parsed.source.evidence).toBe(evidence); - expect(inspectAnalysisView(input)).toMatchObject({ - ok: false, - error: { _tag: "AnalysisInputError" }, - }); - expect(accessed).toBe(false); +it("rejects accessor-backed inline Evidence before all view projections", () => { + const views = [ + { kind: "summary" }, + { kind: "capture" }, + { kind: "api-surface" }, + ] as const; + for (const view of views) { + let accessed = false; + const evidence = { + get normalized_result() { + accessed = true; + throw new Error("Evidence accessor must not be invoked"); + }, + }; + const result = inspectAnalysisView({ + source: { kind: "inline", evidence }, + view, + }); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + expect(accessed).toBe(false); + } + + const parent = analysisViewCaptureEvidence(); + for (const view of views) + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view, + }).ok, + ).toBe(true); }); it("keeps inline-envelope admission live alongside capture validation and grouping", () => { diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index f12033ee0..4e68dd868 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -5,7 +5,10 @@ import type { EvidenceLookup } from "../EvidenceInputResolver.js"; import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { @@ -159,6 +162,18 @@ const resolveAnalysisViewSource = ( ): Result => { if (source.kind === "retained-evidence") return resolveEvidenceInput(source, lookup); + const invalidDataPath = inlineEvidenceInvalidDataPath(source.evidence); + if (invalidDataPath !== undefined) + return err( + new AnalysisInputError(INSPECT_ANALYSIS_VIEW_OPERATION, undefined, [ + { + path: ["source", "evidence", ...invalidDataPath], + reason: "invalid_type", + message: + "Inline Evidence must contain plain JSON data properties without accessors or cycles.", + }, + ]), + ); try { return ok(parseEvidence(source.evidence)); } catch (cause: unknown) { @@ -185,6 +200,53 @@ const resolveAnalysisViewSource = ( } }; +/** Reject non-JSON object behavior before Zod reads caller-owned Evidence. */ +const inlineEvidenceInvalidDataPath = ( + root: unknown, +): readonly (string | number)[] | undefined => { + const active = new Set(); + const pending: { + readonly value: unknown; + readonly path: readonly (string | number)[]; + readonly exit?: object; + }[] = [{ value: root, path: [] }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) continue; + if (current.exit !== undefined) { + active.delete(current.exit); + continue; + } + const { value, path } = current; + if (typeof value !== "object" || value === null) continue; + const isArray = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ( + (isArray && prototype !== Array.prototype) || + (!isArray && prototype !== Object.prototype && prototype !== null) || + active.has(value) + ) + return path; + + active.add(value); + pending.push({ value, path, exit: value }); + const keys = Object.getOwnPropertyNames(value); + for (let index = keys.length - 1; index >= 0; index -= 1) { + const key = keys[index]; + if (key === undefined) continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + const childPath = [ + ...path, + isArray && /^(0|[1-9]\d*)$/u.test(key) ? Number(key) : key, + ]; + if (descriptor === undefined || !Object.hasOwn(descriptor, "value")) + return childPath; + pending.push({ value: descriptor.value, path: childPath }); + } + } + return undefined; +}; + const createAnalysisViewEvidence = ( input: InspectAnalysisViewInput, parent: Evidence, From fd8a2e98ff431fe9d28257d97db502039778cf58 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:55:09 +0800 Subject: [PATCH 17/18] fix(verifiers): own browser fixture setup and cleanup in one scope --- scripts/lib/browser-module-e2e.mjs | 66 ++++++++------ scripts/lib/browser-network-e2e.mjs | 97 +++++++++++---------- scripts/lib/browser-script-export-e2e.mjs | 54 +++++++----- scripts/lib/browser-source-map-e2e.mjs | 64 ++++++++------ scripts/verify-browser-dom-destinations.mjs | 22 +++-- scripts/verify-browser-modules.mjs | 2 + scripts/verify-browser-network.mjs | 2 + scripts/verify-browser-scripts.mjs | 2 + 8 files changed, 184 insertions(+), 125 deletions(-) diff --git a/scripts/lib/browser-module-e2e.mjs b/scripts/lib/browser-module-e2e.mjs index 2bb656adb..5c6a154f3 100644 --- a/scripts/lib/browser-module-e2e.mjs +++ b/scripts/lib/browser-module-e2e.mjs @@ -14,36 +14,42 @@ import { webModuleTraceResultSchema } from "../../dist/domain/webModuleTrace.js" import { NativeModuleResolver } from "../../dist/browser/modules/NativeModuleResolver.js"; import { startBrowserModuleSite } from "../fixtures/browser-module-site.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; +import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs"; /** Check native URL resolution against an independently loaded module graph through CLI/MCP. */ export async function verifyBrowserModules( executable, entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), ) { - const site = await startBrowserModuleSite(); - const root = await mkdtemp(join(tmpdir(), "rea-browser-modules-")); - const env = { - ...process.env, - REA_LOG_LEVEL: "silent", - REA_BROWSER_EXECUTABLE: executable, - HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", - }; - const cli = async (...args) => { - const { stdout } = await promisify(execFile)( - process.execPath, - [entrypoint, ...args, "--json"], - { env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 }, - ); - return parseEvidence(JSON.parse(stdout)); - }; - const transport = new StdioClientTransport({ - command: process.execPath, - args: [entrypoint, "mcp"], - env, - stderr: "pipe", - }); - const client = new Client({ name: "module-real-e2e", version: "1" }); + let site; + let root; + let transport; + let client; + let primaryError; try { + site = await startBrowserModuleSite(); + root = await mkdtemp(join(tmpdir(), "rea-browser-modules-")); + const env = { + ...process.env, + REA_LOG_LEVEL: "silent", + REA_BROWSER_EXECUTABLE: executable, + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", + }; + const cli = async (...args) => { + const { stdout } = await promisify(execFile)( + process.execPath, + [entrypoint, ...args, "--json"], + { env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 }, + ); + return parseEvidence(JSON.parse(stdout)); + }; + transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + client = new Client({ name: "module-real-e2e", version: "1" }); const oracle = await loadingOracle(executable, site); const scenario = { browser: { mode: "launch", executable_path: executable }, @@ -298,11 +304,17 @@ export async function verifyBrowserModules( map_identity_verified: true, implicit_refetches: 0, }; + } catch (cause) { + primaryError = cause; + throw cause; } finally { - await client.close(); - await transport.close(); - await site.close(); - await rm(root, { recursive: true, force: true }); + const cleanup = []; + if (client !== undefined) cleanup.push(() => client.close()); + if (transport !== undefined) cleanup.push(() => transport.close()); + if (site !== undefined) cleanup.push(() => site.close()); + if (root !== undefined) + cleanup.push(() => rm(root, { recursive: true, force: true })); + await closeRuntimeFixtureResources(cleanup, primaryError); } } diff --git a/scripts/lib/browser-network-e2e.mjs b/scripts/lib/browser-network-e2e.mjs index 4cf369f63..33aad95c7 100644 --- a/scripts/lib/browser-network-e2e.mjs +++ b/scripts/lib/browser-network-e2e.mjs @@ -14,6 +14,7 @@ import { } from "../fixtures/browser-network-site.mjs"; import { assertBrowserNetworkEvidence } from "./browser-network-assertions.mjs"; import { scenarioProfiles } from "./browser-scenario-verifier.mjs"; +import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs"; /** Exercise selected network content through real CLI and stdio MCP, one browser at a time. */ export async function verifyBrowserNetworkEvidence( @@ -21,9 +22,14 @@ export async function verifyBrowserNetworkEvidence( entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), attachment, ) { - const site = await startBrowserNetworkSite(); - const profilesBefore = await scenarioProfiles(); + let site; + let profilesBefore; + let transport; + let client; + let primaryError; try { + site = await startBrowserNetworkSite(); + profilesBefore = await scenarioProfiles(); const scenario = browserScenarioSchema.parse({ browser: { mode: "launch", executable_path: executable }, start_url: { url: site.origin }, @@ -97,58 +103,50 @@ export async function verifyBrowserNetworkEvidence( () => assertBrowserNetworkEvidence(swapped, site.origin), /associated with a different same-URL request/, ); - const transport = new StdioClientTransport({ + transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], env, stderr: "pipe", }); - const client = new Client({ name: "network-real-e2e", version: "1" }); + client = new Client({ name: "network-real-e2e", version: "1" }); let mcpProof; - try { - await client.connect(transport); - const result = await client.callTool( - { name: "capture_browser_scenario", arguments: scenario }, + await client.connect(transport); + const result = await client.callTool( + { name: "capture_browser_scenario", arguments: scenario }, + { timeout: 60_000 }, + ); + assert.notEqual(result.isError, true, JSON.stringify(result)); + const evidence = parseEvidence(result.structuredContent); + mcpProof = assertBrowserNetworkEvidence( + browserScenarioCaptureSchema.parse(evidence.normalized_result), + site.origin, + ); + if (attachment !== undefined) { + const connectedScenario = browserScenarioSchema.parse({ + ...scenario, + browser: { mode: "connect", ...attachment }, + }); + const connected = await client.callTool( + { name: "capture_browser_scenario", arguments: connectedScenario }, { timeout: 60_000 }, ); - assert.notEqual(result.isError, true, JSON.stringify(result)); - const evidence = parseEvidence(result.structuredContent); - mcpProof = assertBrowserNetworkEvidence( - browserScenarioCaptureSchema.parse(evidence.normalized_result), - site.origin, - ); - if (attachment !== undefined) { - const connectedScenario = browserScenarioSchema.parse({ - ...scenario, - browser: { mode: "connect", ...attachment }, - }); - const connected = await client.callTool( - { name: "capture_browser_scenario", arguments: connectedScenario }, - { timeout: 60_000 }, - ); - assert.notEqual(connected.isError, true, JSON.stringify(connected)); - const connectedEvidence = parseEvidence(connected.structuredContent); - assert.deepEqual( - assertBrowserNetworkEvidence( - browserScenarioCaptureSchema.parse( - connectedEvidence.normalized_result, - ), - site.origin, - "disconnected-external", + assert.notEqual(connected.isError, true, JSON.stringify(connected)); + const connectedEvidence = parseEvidence(connected.structuredContent); + assert.deepEqual( + assertBrowserNetworkEvidence( + browserScenarioCaptureSchema.parse( + connectedEvidence.normalized_result, ), - cliProof, - ); - assert.ok( - (await fetch(`${attachment.cdp_endpoint}/json/version`)).ok, - "Network capture closed the external browser", - ); - } - } finally { - try { - await client.close(); - } finally { - await transport.close(); - } + site.origin, + "disconnected-external", + ), + cliProof, + ); + assert.ok( + (await fetch(`${attachment.cdp_endpoint}/json/version`)).ok, + "Network capture closed the external browser", + ); } assert.deepEqual(mcpProof, cliProof); for (const path of [ @@ -180,7 +178,14 @@ export async function verifyBrowserNetworkEvidence( profile_cleanup: true, connected_network_verified: attachment !== undefined, }; + } catch (cause) { + primaryError = cause; + throw cause; } finally { - await site.close(); + const cleanup = []; + if (client !== undefined) cleanup.push(() => client.close()); + if (transport !== undefined) cleanup.push(() => transport.close()); + if (site !== undefined) cleanup.push(() => site.close()); + await closeRuntimeFixtureResources(cleanup, primaryError); } } diff --git a/scripts/lib/browser-script-export-e2e.mjs b/scripts/lib/browser-script-export-e2e.mjs index 17fe25551..ce1599e17 100644 --- a/scripts/lib/browser-script-export-e2e.mjs +++ b/scripts/lib/browser-script-export-e2e.mjs @@ -15,6 +15,7 @@ import { assertExportedScriptAnalysis, } from "./browser-script-export-assertions.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; +import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs"; /** Verify capture → export → existing analysis through real CLI and stdio MCP. */ export async function verifyBrowserScriptExport( @@ -22,24 +23,29 @@ export async function verifyBrowserScriptExport( entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), attachment, ) { - const site = await startBrowserScriptSite(); - const root = await mkdtemp(join(tmpdir(), "rea-browser-script-export-")); - const env = { - ...process.env, - REA_LOG_LEVEL: "silent", - HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", - }; - const cli = exportCli(entrypoint, env); - const scenario = scriptCaptureScenario(executable, site.origin); - const save = (name, capture) => saveCapture(root, name, capture); - const transport = new StdioClientTransport({ - command: process.execPath, - args: [entrypoint, "mcp"], - env, - stderr: "pipe", - }); - const client = new Client({ name: "web-script-real-e2e", version: "1" }); + let site; + let root; + let transport; + let client; + let primaryError; try { + site = await startBrowserScriptSite(); + root = await mkdtemp(join(tmpdir(), "rea-browser-script-export-")); + const env = { + ...process.env, + REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", + }; + const cli = exportCli(entrypoint, env); + const scenario = scriptCaptureScenario(executable, site.origin); + const save = (name, capture) => saveCapture(root, name, capture); + transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + client = new Client({ name: "web-script-real-e2e", version: "1" }); const cliCapture = await cli( "capture-browser-scenario", JSON.stringify(scenario), @@ -103,11 +109,17 @@ export async function verifyBrowserScriptExport( query_variants_isolated: true, implicit_refetches: 0, }; + } catch (cause) { + primaryError = cause; + throw cause; } finally { - await client.close(); - await transport.close(); - await site.close(); - await rm(root, { recursive: true, force: true }); + const cleanup = []; + if (client !== undefined) cleanup.push(() => client.close()); + if (transport !== undefined) cleanup.push(() => transport.close()); + if (site !== undefined) cleanup.push(() => site.close()); + if (root !== undefined) + cleanup.push(() => rm(root, { recursive: true, force: true })); + await closeRuntimeFixtureResources(cleanup, primaryError); } } diff --git a/scripts/lib/browser-source-map-e2e.mjs b/scripts/lib/browser-source-map-e2e.mjs index efd12e648..635dcb5e6 100644 --- a/scripts/lib/browser-source-map-e2e.mjs +++ b/scripts/lib/browser-source-map-e2e.mjs @@ -12,35 +12,41 @@ import { parseEvidence } from "../../dist/domain/evidence.js"; import { webSourceLocationResultSchema } from "../../dist/domain/webSourceLocation.js"; import { startBrowserSourceMapSite } from "../fixtures/browser-source-map-site.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; +import { closeRuntimeFixtureResources } from "./browser-runtime-fixture-lifecycle.mjs"; /** Verify real Chrome capture → export → original compiler point through packaged CLI/MCP. */ export async function verifyBrowserSourceMap( executable, entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), ) { - const site = await startBrowserSourceMapSite(); - const root = await mkdtemp(join(tmpdir(), "rea-browser-source-map-")); - const env = { - ...process.env, - REA_LOG_LEVEL: "silent", - HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", - }; - const cli = async (...args) => { - const { stdout } = await promisify(execFile)( - process.execPath, - [entrypoint, ...args, "--json"], - { env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 }, - ); - return parseEvidence(JSON.parse(stdout)); - }; - const transport = new StdioClientTransport({ - command: process.execPath, - args: [entrypoint, "mcp"], - env, - stderr: "pipe", - }); - const client = new Client({ name: "source-map-real-e2e", version: "1" }); + let site; + let root; + let transport; + let client; + let primaryError; try { + site = await startBrowserSourceMapSite(); + root = await mkdtemp(join(tmpdir(), "rea-browser-source-map-")); + const env = { + ...process.env, + REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", + }; + const cli = async (...args) => { + const { stdout } = await promisify(execFile)( + process.execPath, + [entrypoint, ...args, "--json"], + { env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 }, + ); + return parseEvidence(JSON.parse(stdout)); + }; + transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + client = new Client({ name: "source-map-real-e2e", version: "1" }); const capture = await cli( "capture-browser-scenario", JSON.stringify({ @@ -126,11 +132,17 @@ export async function verifyBrowserSourceMap( selected_inert_userinfo_context_preserved: true, public_point_cases: 4, }; + } catch (cause) { + primaryError = cause; + throw cause; } finally { - await client.close(); - await transport.close(); - await site.close(); - await rm(root, { recursive: true, force: true }); + const cleanup = []; + if (client !== undefined) cleanup.push(() => client.close()); + if (transport !== undefined) cleanup.push(() => transport.close()); + if (site !== undefined) cleanup.push(() => site.close()); + if (root !== undefined) + cleanup.push(() => rm(root, { recursive: true, force: true })); + await closeRuntimeFixtureResources(cleanup, primaryError); } } function assertPoint(evidence, { site, input, capture, identity }) { diff --git a/scripts/verify-browser-dom-destinations.mjs b/scripts/verify-browser-dom-destinations.mjs index 31b5d1e17..78ee670cb 100644 --- a/scripts/verify-browser-dom-destinations.mjs +++ b/scripts/verify-browser-dom-destinations.mjs @@ -6,15 +6,21 @@ import { join } from "node:path"; import { chromium } from "playwright-core"; import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs"; import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs"; +import { closeRuntimeFixtureResources } from "./lib/browser-runtime-fixture-lifecycle.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; const executable = process.env.REA_BROWSER_EXECUTABLE; if (!executable) throw new Error("verify:browser:dom requires REA_BROWSER_EXECUTABLE"); -const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-")); -const site = await startBrowserVerifierSite(); +await requireOrdinaryWindowsVerifierCaller(); +let profile; +let site; let context; let report; +let primaryError; try { + profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-")); + site = await startBrowserVerifierSite(); context = await chromium.launchPersistentContext(profile, { executablePath: executable, headless: true, @@ -50,9 +56,15 @@ try { native_oracle: true, verified: true, }; +} catch (cause) { + primaryError = cause; + throw cause; } finally { - await context?.close(); - await site.close(); - await rm(profile, { recursive: true, force: true }); + const cleanup = []; + if (context !== undefined) cleanup.push(() => context.close()); + if (site !== undefined) cleanup.push(() => site.close()); + if (profile !== undefined) + cleanup.push(() => rm(profile, { recursive: true, force: true })); + await closeRuntimeFixtureResources(cleanup, primaryError); } process.stdout.write(`${JSON.stringify(report)}\n`); diff --git a/scripts/verify-browser-modules.mjs b/scripts/verify-browser-modules.mjs index 23f2f9a76..df195938e 100644 --- a/scripts/verify-browser-modules.mjs +++ b/scripts/verify-browser-modules.mjs @@ -2,12 +2,14 @@ import { access } from "node:fs/promises"; import { verifyBrowserModules } from "./lib/browser-module-e2e.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; const executable = process.env.REA_BROWSER_EXECUTABLE; if (!executable) throw new Error( "verify:browser:modules requires REA_BROWSER_EXECUTABLE for caller-supplied Chromium", ); +await requireOrdinaryWindowsVerifierCaller(); await access(executable); const run = createVerifierRun(); const proof = await verifyBrowserModules(executable, process.argv[2]); diff --git a/scripts/verify-browser-network.mjs b/scripts/verify-browser-network.mjs index f7fa0222b..d7c0200da 100644 --- a/scripts/verify-browser-network.mjs +++ b/scripts/verify-browser-network.mjs @@ -2,12 +2,14 @@ import { access } from "node:fs/promises"; import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; const executable = process.env.REA_BROWSER_EXECUTABLE; if (executable === undefined || executable === "") throw new Error( "verify:browser:network requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser", ); +await requireOrdinaryWindowsVerifierCaller(); await access(executable); const run = createVerifierRun(); const proof = await verifyBrowserNetworkEvidence(executable, process.argv[2]); diff --git a/scripts/verify-browser-scripts.mjs b/scripts/verify-browser-scripts.mjs index 30fba281b..0c5661379 100644 --- a/scripts/verify-browser-scripts.mjs +++ b/scripts/verify-browser-scripts.mjs @@ -2,12 +2,14 @@ import { access } from "node:fs/promises"; import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs"; const executable = process.env.REA_BROWSER_EXECUTABLE; if (executable === undefined || executable === "") throw new Error( "verify:browser:scripts requires REA_BROWSER_EXECUTABLE pointing to an installed Chrome-family browser", ); +await requireOrdinaryWindowsVerifierCaller(); await access(executable); const run = createVerifierRun(); const proof = await verifyBrowserScriptExport(executable, process.argv[2]); From 70771523c5759a28f7250e96bfc5fb6646eefe90 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:55:16 +0800 Subject: [PATCH 18/18] chore(licenses): preserve locked XML parser runtime notices --- licenses/npm-runtime-notices.txt | 107 ++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index a2032f7a7..81adcd9ab 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): b0d0151958456c6e72c5720af80d783893be88a7b4c9a409db8505296fe10bd9 +Runtime dependency fingerprint (SHA-256): 49b181923ab240c68c64d35ecdd8f93cb5cd516c765f2282d0e0eca0bcadd13e ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -8441,3 +8441,108 @@ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +===== saxes@6.0.0 ===== +Declared license: ISC +Archive: https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz +Integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== +Scope: npm runtime dependency, including optional platform packages. + +The locked npm archive omits LICENSE; preserved from the matching v6.0.0 upstream tag. +Source: https://raw.githubusercontent.com/lddubeau/saxes/v6.0.0/LICENSE +SHA-256: 0fac2374380621b22e6b50451057721a9c52935b02d16d106a9f04897f061d0e + +--- upstream LICENSE --- + +The ISC License + +Copyright (c) Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==== + +The following license is the one that governed sax, from which saxes +was forked. Isaac Schlueter is not *directly* involved with saxes so +don't go bugging him for saxes issues. + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==== + +`String.fromCodePoint` by Mathias Bynens is no longer used, but it can +still be found in old commits. It was once used according to terms of +MIT License, as follows: + + Copyright Mathias Bynens + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +===== xmlchars@2.2.0 ===== +Declared license: MIT +Archive: https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz +Integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +Scope: npm runtime dependency, including optional platform packages. + +--- package/LICENSE --- + +Copyright Louis-Dominique Dubeau and contributors to xmlchars + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.