diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 39b003844b..372934b7ad 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -22,7 +22,7 @@ The tool reads the caller-selected local directory or ASAR path directly: ```bash rea analyze /absolute/path/to/apps/app.asar --json rea analyze-javascript-application /absolute/path/to/apps/app.asar \ - --json + --integrity-policy record-and-continue --json ``` Generic `rea analyze` selects this static provider for directories and `.asar` @@ -47,11 +47,14 @@ The equivalent MCP input is: ```json { "input_path": "/absolute/path/to/apps/app.asar", - "format": "auto" + "format": "auto", + "integrity_policy": "fail" } ``` `input_path` must be absolute. `format` accepts `auto`, `asar`, or `directory`. +`integrity_policy` accepts `fail` or `record-and-continue`; both CLI routes and +the MCP tool default to `fail`. The result retains the canonical local path, root artifact digest, artifact manifest and graph commitments, JavaScript Application Graph, static Electron summary, reconstruction statistics, and explicit limitations. It does @@ -63,8 +66,12 @@ ASAR inventory checks Electron integrity metadata for embedded archive entries and supplied `.asar.unpacked` companion files. An integrity failure identifies the logical path, declared and calculated SHA-256 values, and whether the entry was unpacked. By default, a mismatch is returned as a failure with its artifact -context. Requests that support `integrity_policy` can explicitly select -`record-and-continue` to inspect verified siblings while retaining the mismatch. +context. Application analysis can explicitly select `record-and-continue` to +analyze observed bytes while retaining each contradiction's declared and +observed hashes and `observed-untrusted` trust. Such results mark application +graph coverage partial and include every contradicted path in the limitations. +Mismatched nested ASARs stay opaque rather than being expanded. An unpacked +entry whose companion bytes were not supplied remains `unavailable`. An unpacked entry whose companion bytes were not supplied remains `unavailable`. REA continues analyzing embedded JavaScript and records the diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index d1e95d1b8e..f25ea8a574 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -339,3 +339,9 @@ Contradictory bytes are quarantined from nested expansion and recorded with declared and observed hashes, trust, provenance, path, and unpacked state. Verified siblings continue. Comparisons classify the result as a contradiction and reconstruction cannot treat it as unchanged. + +`analyze_javascript_application` accepts the same policy in MCP and as +`--integrity-policy` on both JavaScript CLI routes. Its result returns the +canonical `integrity_contradictions` records and marks application graph +coverage partial when any mismatch is continued. Contradicted nested ASARs +remain opaque. diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index 34753c1270..d3e3862d30 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -90,4 +90,5 @@ const parameters = ( input: AnalyzeJavaScriptApplicationInput, ): EvidenceObservation["parameters"] => ({ format: input.format, + integrity_policy: input.integrity_policy, }); diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 28f6691ee7..9953566985 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -60,6 +60,7 @@ export const analyzeJavaScriptApplicationValidated = async ( { input_path: input.input_path, format: input.format, + integrity_policy: input.integrity_policy, }, options.signal, options.progress, diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 0c39ce458a..29648aa442 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -140,6 +140,13 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => { null, ); if (unknownGap) return partialApplicationCoverage([], null); + if (context.snapshot.integrity_contradictions.length > 0) { + const nestedArchiveWasOpaque = + context.snapshot.integrity_contradictions.some(({ logical_path }) => + logical_path.toLowerCase().endsWith(".asar"), + ); + return partialApplicationCoverage([], nestedArchiveWasOpaque ? null : 0); + } return completeApplicationCoverage(); }; @@ -178,6 +185,10 @@ const graphLimitations = ( ); return [ ...context.analysis.limitations, + ...context.snapshot.integrity_contradictions.map( + ({ logical_path: path }) => + `Artifact integrity metadata contradicts observed bytes at ${path}; the observed bytes are untrusted.`, + ), ...selfReferenceOmissions( relationshipOmissions.selfImports, ["import specifier", "import specifiers"], diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 692321d8c7..39ff299f3c 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -11,6 +11,7 @@ import { createJavaScriptArtifactReader as createReader } from "../../artifacts/ import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; +import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js"; import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js"; @@ -34,6 +35,7 @@ export interface JavaScriptArtifactReconstructionResult { readonly root_artifact_sha256: string; readonly inventory_manifest_id: string; readonly inventory_graph_sha256: string; + readonly integrity_contradictions: ArtifactInventorySnapshot["integrity_contradictions"]; readonly graph: JavaScriptApplicationGraph; readonly semantic_graph: JavaScriptSemanticGraph; readonly electron_summary: ElectronBoundarySummary; @@ -66,7 +68,10 @@ export const reconstructJavaScriptArtifact = async ( abortIfNeeded(signal); const path = await resolveSelectedInput(input.input_path); const format = await resolveFormat(path, input); - const snapshot = await scanCanonicalArtifactInventory(path, { signal }); + const snapshot = await scanCanonicalArtifactInventory(path, { + signal, + integrity: { mode: input.integrity_policy }, + }); if (snapshot.manifest.root_format !== format) throw new ArtifactReaderFailure( "format", @@ -141,6 +146,7 @@ export const reconstructJavaScriptArtifact = async ( root_artifact_sha256: snapshot.manifest.root_sha256, inventory_manifest_id: snapshot.manifest.manifest_id, inventory_graph_sha256: snapshot.manifest.graph_sha256, + integrity_contradictions: snapshot.integrity_contradictions, graph, semantic_graph: semanticGraph, electron_summary: summarizeElectronBoundaries(analysis), diff --git a/src/application/javascript/JavaScriptArtifactReconstructionInput.ts b/src/application/javascript/JavaScriptArtifactReconstructionInput.ts index 3bdb9b6395..f2988ee2b3 100644 --- a/src/application/javascript/JavaScriptArtifactReconstructionInput.ts +++ b/src/application/javascript/JavaScriptArtifactReconstructionInput.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const javascriptArtifactReconstructionInputSchema = z.strictObject({ input_path: z.string().min(1), format: z.enum(["auto", "asar", "directory"]).default("auto"), + integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), }); /** Parsed local reconstruction request. */ diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index a726fd88a1..a6aa2600ff 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -15,6 +15,7 @@ import { } from "../ArtifactReader.js"; import { streamChunkToBuffer } from "../StreamBytes.js"; import { hashReadable } from "../ArtifactHash.js"; +import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { @@ -32,9 +33,14 @@ interface ExpectedFile { readonly kind: JavaScriptArtifactFileKind; } +interface ExpectedContainer { + readonly hash_status: ArtifactOccurrence["hash_status"]; + readonly inventory: JavaScriptArtifactContainer | null; +} + interface ReadContext { readonly expected: ReadonlyMap; - readonly expectedContainers: ReadonlyMap; + readonly expectedContainers: ReadonlyMap; readonly registry: ArtifactPathRegistry; readonly files: JavaScriptArtifactFile[]; readonly containers: JavaScriptArtifactContainer[]; @@ -136,7 +142,32 @@ const visitReader = async ( const nestedAsar = isFilesystemAsar(entry, path); context.registry.add(path, nestedAsar ? "directory" : entry.kind); if (nestedAsar) { - const inventory = expectedContainer(path, context); + const container = context.expectedContainers.get(path); + // Inventory deliberately does not expand unverified or unavailable + // nested archives. Keep the same boundary here when reconstruction + // reads the active entries again. + if (container === undefined) + throw new ArtifactReaderFailure( + "integrity", + `Nested ASAR was not present in inventory: ${path}`, + ); + if (container.hash_status === "mismatched") continue; + if (container.hash_status === "unavailable") + throw new ArtifactReaderFailure( + "unavailable", + `Nested ASAR bytes were unavailable during inventory: ${path}`, + ); + if (container.hash_status !== "verified") + throw new ArtifactReaderFailure( + "integrity", + `Nested ASAR was not integrity-verified: ${path}`, + ); + const inventory = container.inventory; + if (inventory === null) + throw new ArtifactReaderFailure( + "integrity", + `Verified nested ASAR has no inventory node: ${path}`, + ); await verifyEntryBytes( await frame.reader.open(entry, context.signal), inventory, @@ -183,19 +214,6 @@ const visitReader = async ( } }; -const expectedContainer = ( - path: string, - context: ReadContext, -): JavaScriptArtifactContainer => { - const container = context.expectedContainers.get(path); - if (container === undefined) - throw new ArtifactReaderFailure( - "integrity", - `Nested ASAR disappeared from inventory: ${path}`, - ); - return container; -}; - const readText = async ( context: ReadContext, input: ReadTextInput, @@ -259,14 +277,24 @@ const expectedInventory = ( snapshot: ArtifactInventorySnapshot, ): { readonly files: ReadonlyMap; - readonly containers: ReadonlyMap; + readonly containers: ReadonlyMap; } => { const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node])); const files = new Map(); - const containers = new Map(); + const containers = new Map(); for (const occurrence of snapshot.occurrences) { - if (occurrence.artifact_id === null || occurrence.logical_path === ".") + if (occurrence.logical_path === ".") continue; + const isNestedAsar = + occurrence.entry_kind === "file" && + occurrence.logical_path.toLowerCase().endsWith(".asar"); + if (occurrence.artifact_id === null) { + if (isNestedAsar) + containers.set(occurrence.logical_path, { + hash_status: occurrence.hash_status, + inventory: null, + }); continue; + } const node = nodes.get(occurrence.artifact_id); if (node === undefined) throw new ArtifactReaderFailure( @@ -277,12 +305,15 @@ const expectedInventory = ( // Suffixes only describe file candidates; a directory such as // node_modules/@zip.js remains a graph node and must never be opened. if (occurrence.entry_kind !== "file") continue; - if (occurrence.logical_path.toLowerCase().endsWith(".asar")) + if (isNestedAsar) containers.set(occurrence.logical_path, { - path: occurrence.logical_path, - sha256: node.sha256, - bytes: node.size, - inventory_artifact_id: node.artifact_id, + hash_status: occurrence.hash_status, + inventory: { + path: occurrence.logical_path, + sha256: node.sha256, + bytes: node.size, + inventory_artifact_id: node.artifact_id, + }, }); const kind = relevantKind(occurrence.logical_path); if (kind === undefined) continue; diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index d3623141b9..5130b8634a 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -16,6 +16,7 @@ import type { CliInstance } from "./types.js"; import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js"; import { withCommandCancellation } from "./commandCancellation.js"; import type { CliResultOutput } from "./streamedJsonOutput.js"; +import { javascriptApplicationOptions } from "../cliObservationOptions.js"; /** Register provider-neutral binary overview and procedure CLI commands. */ export const registerCoreBinaryCommands = ( @@ -53,7 +54,10 @@ const registerOverviewCommands = ( args: z.object({ path: z.string().describe("App, program, or analysis database path"), }), - options: overviewOptions, + options: overviewOptions.extend({ + integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy, + }), + alias: { integrityPolicy: "integrity-policy" }, run: async ({ args, options, format }) => { const output = resultOutput === undefined @@ -66,7 +70,10 @@ const registerOverviewCommands = ( return withCommandCancellation((signal) => logCliCommand(logger, "analyze", () => runCliJavaScriptApplicationAnalysis( - { input_path: resolve(args.path) }, + { + input_path: resolve(args.path), + integrity_policy: options.integrityPolicy, + }, output, signal, ), diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 6b11183d79..79dcd7cc1e 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -193,11 +193,16 @@ const registerJavaScriptApplicationCommand = ( path: z.string().describe("ASAR or extracted application path"), }), options: javascriptApplicationOptions, + alias: { integrityPolicy: "integrity-policy" }, run: ({ args, options, format }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () => runCliJavaScriptApplicationAnalysis( - { input_path: args.path, format: options.artifactFormat }, + { + input_path: args.path, + format: options.artifactFormat, + integrity_policy: options.integrityPolicy, + }, resultOutput === undefined ? undefined : { diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index 1131e94dd6..cdb1b1f0ec 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -63,4 +63,8 @@ export const javascriptApplicationOptions = z.object({ .enum(["auto", "asar", "directory"]) .default("auto") .describe("Application artifact format"), + integrityPolicy: z + .enum(["fail", "record-and-continue"]) + .default("fail") + .describe("Behavior when declared application integrity does not match"), }); diff --git a/src/contracts/javascript/electronToolContracts.ts b/src/contracts/javascript/electronToolContracts.ts index e35e134b5a..659ea99f03 100644 --- a/src/contracts/javascript/electronToolContracts.ts +++ b/src/contracts/javascript/electronToolContracts.ts @@ -79,7 +79,7 @@ export const ELECTRON_TOOL_CONTRACTS = [ name: "analyze_javascript_application", ...toolContractMetadata("analyze_javascript_application"), description: - "Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns all recovered graph nodes, edges, semantic relations, limitations, and coverage.", + "Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns recovered graph nodes, edges, semantic relations, integrity contradictions, limitations, and coverage. Integrity mismatches fail by default; record-and-continue retains observed bytes as untrusted and marks graph coverage partial.", kind: "electron-provider", inputSchema: analyzeJavaScriptApplicationInputSchema, outputSchema: applicationOutputSchema, @@ -89,6 +89,7 @@ export const ELECTRON_TOOL_CONTRACTS = [ input: { input_path: "/Applications/Example.app/Contents/Resources/app.asar", format: "auto", + integrity_policy: "fail", }, }, ], diff --git a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts index 69deff0acc..2fb7b96cc7 100644 --- a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts +++ b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts @@ -28,6 +28,7 @@ const captures = { "agm_f30ded9a83d43a3980417bbe63dfad569e83386f243c4d6a01af0166372cb9e6", inventory_graph_sha256: "4b8aceaf67e43fd8317ec1f5fd1df5235f8fa402335accf011683e008c6bc62c", + integrity_contradictions: [], }, total: { input_path: "/examples/javascript-export-presence/right", @@ -37,6 +38,7 @@ const captures = { "agm_d3ceaa1af38fb3474070ca25c0d4644282e5fb7d5824cee3e3a81391481e9fc1", inventory_graph_sha256: "3e9bc926951480c35385efe5650d9d781631bd74c37a523d543b5fbe06b01ce6", + integrity_contradictions: [], }, }; const summary = { diff --git a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts index 127e137998..bd3991dd6a 100644 --- a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts +++ b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts @@ -128,6 +128,7 @@ export const JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE = createEvidence( root_artifact_sha256: applicationSha256, inventory_manifest_id: `agm_${"2".repeat(64)}`, inventory_graph_sha256: "3".repeat(64), + integrity_contradictions: [], graph, semantic_graph: semanticGraph, summary: { diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 93ffeb4fbf..7c71a9b5dc 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -191,7 +191,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { const standardMessage = standardErrorMessage(error._tag); if (standardMessage !== undefined) return standardMessage; if (error instanceof ArtifactOperationError) - return artifactMessage(error.reason); + return artifactMessage(error.operation, error.reason); if (error instanceof EvidenceReferenceError) return error.reason === "missing" ? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.` @@ -326,7 +326,10 @@ const START_FAILURE_TAGS: ReadonlySet = new Set([ "HopperStartError", ]); -const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { +const artifactMessage = ( + operation: ArtifactOperationError["operation"], + reason: ArtifactOperationError["reason"], +): string => { if (reason === "cancelled") return "Artifact operation was cancelled. Start it again when ready."; if (reason === "limit") @@ -335,6 +338,8 @@ const artifactMessage = (reason: ArtifactOperationError["reason"]): string => { return "Artifact contains an unsafe or conflicting internal path. Inspect the reported path and correct the artifact before retrying."; if (reason === "unavailable") return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools."; + if (reason === "integrity" && operation === "analyze_javascript_application") + return "Artifact bytes contradict declared integrity. If expected, rerun analyze_javascript_application with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy."; if (reason === "format" || reason === "integrity") return "Artifact is invalid or has changed. Get a fresh copy and try again."; return "Artifact could not be read or written. Check file access and try again."; diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 721c007df8..34e4af9323 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -137,7 +137,7 @@ const artifactOccurrenceSchema = z.object({ }); /** Observed bytes that contradict a container's declared integrity identity. */ -const integrityContradictionSchema = z.object({ +export const integrityContradictionSchema = z.object({ contradiction_id: contradictionIdSchema, occurrence_id: occurrenceIdSchema, parent_artifact_id: artifactIdSchema, diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index 313ab52912..8d976d7fbd 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -12,6 +12,7 @@ import { } from "./javascriptSemanticGraph.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; +import { integrityContradictionSchema } from "../artifactGraph.js"; import { localPathStringSchema } from "../localPath.js"; const countSchema = z.number().int().min(0); @@ -20,6 +21,7 @@ const countSchema = z.number().int().min(0); export const analyzeJavaScriptApplicationInputSchema = z.strictObject({ input_path: localPathStringSchema, format: z.enum(["auto", "asar", "directory"]).default("auto"), + integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), }); /** Compact counts for the high-level Electron architecture/security surface. */ @@ -66,6 +68,7 @@ const applicationAnalysisResultShape = z.strictObject({ root_artifact_sha256: digestSchema, inventory_manifest_id: prefixedDigestSchema("agm"), inventory_graph_sha256: digestSchema, + integrity_contradictions: z.array(integrityContradictionSchema), graph: javascriptApplicationGraphSchema, summary: electronBoundarySummarySchema, statistics: reconstructionStatisticsSchema, diff --git a/tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts b/tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts new file mode 100644 index 0000000000..4883ac741b --- /dev/null +++ b/tests/boundary/cli/javascriptIntegrityPolicyCli.test.ts @@ -0,0 +1,48 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; + +const execute = promisify(execFile); +const run = async (arguments_: readonly string[]): Promise => { + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", ...arguments_, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + return JSON.parse(stdout); +}; + +it.each(["analyze-javascript-application", "analyze"])( + "%s carries the integrity policy into shared JavaScript analysis", + async (command) => { + const { archive, addon } = await createStrippedAsarAddon(); + const result = await run([ + command, + archive, + "--integrity-policy", + "record-and-continue", + ]); + expect(result).toMatchObject({ + operation: "analyze_javascript_application", + parameters: { integrity_policy: "record-and-continue" }, + normalized_result: { + integrity_contradictions: [ + { logical_path: addon, trust: "observed-untrusted" }, + ], + graph: { coverage: { status: "partial" } }, + }, + }); + }, +); + +it("rejects the JavaScript integrity policy on binary-only inspection", async () => { + await expect( + run(["inspect", "unused.asar", "--integrityPolicy", "record-and-continue"]), + ).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining("Unknown flag: --integrityPolicy"), + }); +}); diff --git a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts new file mode 100644 index 0000000000..7291d888e2 --- /dev/null +++ b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackage, createPackageWithOptions } from "@electron/asar"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; +import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("records an explicitly continued Electron addon mismatch as untrusted evidence", async () => { + const { archive, addon, original, stripped } = + await createStrippedAsarAddon(); + const rejected = await analyzeJavaScriptApplication({ input_path: archive }); + if (rejected.ok) throw new Error("Expected strict integrity failure"); + expect(projectAnalysisError(rejected.error)).toMatchObject({ + code: "artifact_integrity_mismatch", + details: { + logical_path: addon, + declared_sha256: expect.any(String), + calculated_sha256: expect.any(String), + unpacked: true, + }, + remediation: { + action: expect.stringContaining("integrity_policy=record-and-continue"), + }, + }); + + const continued = await analyzeJavaScriptApplication({ + input_path: archive, + integrity_policy: "record-and-continue", + }); + if (!continued.ok) throw new Error("Expected explicit continuation"); + const result = javascriptApplicationAnalysisResultSchema.parse( + continued.value.normalized_result, + ); + const contradiction = result.integrity_contradictions.find( + ({ logical_path }) => logical_path === addon, + ); + expect(contradiction).toMatchObject({ + logical_path: addon, + declared_sha256: createHash("sha256").update(original).digest("hex"), + observed_sha256: createHash("sha256").update(stripped).digest("hex"), + trust: "observed-untrusted", + }); + expect(result.graph.coverage.status).toBe("partial"); + expect(result.graph.coverage.omitted_count).toBe(0); + expect(result.graph.limitations).toContain( + `Artifact integrity metadata contradicts observed bytes at ${addon}; the observed bytes are untrusted.`, + ); + expect(continued.value.parameters).toMatchObject({ + format: "auto", + integrity_policy: "record-and-continue", + }); + expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); + expect(JSON.stringify(result.graph)).toContain( + createHash("sha256").update(stripped).digest("hex"), + ); +}); + +it("keeps a contradicted nested ASAR opaque during JavaScript reconstruction", async () => { + const root = await createTestTempDirectory("rea-mismatched-nested-asar-"); + const nestedSource = join(root, "nested-source"); + const outerSource = join(root, "outer-source"); + const archive = join(root, "app.asar"); + const nestedArchive = join(outerSource, "nested.asar"); + const nestedSecret = "export const nestedOnly = 'not inventoried';\n"; + await mkdir(nestedSource); + await mkdir(outerSource); + await writeFile(join(nestedSource, "main.js"), nestedSecret); + await writeFile(join(outerSource, "main.js"), "export const outer = true;\n"); + await createPackage(nestedSource, nestedArchive); + await createPackageWithOptions(outerSource, archive, { + unpack: "nested.asar", + }); + const unpackedNested = join(`${archive}.unpacked`, "nested.asar"); + const nestedBytes = await readFile(unpackedNested); + nestedBytes[0] = (nestedBytes[0] ?? 0) ^ 0xff; + await writeFile(unpackedNested, nestedBytes); + + const analyzed = await analyzeJavaScriptApplication({ + input_path: archive, + integrity_policy: "record-and-continue", + }); + if (!analyzed.ok) throw new Error("Expected explicit continuation"); + const result = javascriptApplicationAnalysisResultSchema.parse( + analyzed.value.normalized_result, + ); + expect(result.integrity_contradictions).toMatchObject([ + { logical_path: "nested.asar", trust: "observed-untrusted" }, + ]); + expect(result.statistics.nested_asar_containers).toBe(0); + expect(JSON.stringify(result)).not.toContain(nestedSecret); + expect(result.graph.coverage.status).toBe("partial"); + expect(result.graph.coverage.omitted_count).toBeNull(); +}); + +it("retains semantic resource limits alongside recorded integrity contradictions", async () => { + const expression = Array.from( + { length: 20 }, + () => '(true ? "a" : "b")', + ).join(" + "); + const declarations = ['const value0 = "x";']; + for (let index = 1; index <= 30; index += 1) { + const previous = `value${String(index - 1)}`; + declarations.push( + `const value${String(index)} = ${previous} + ${previous};`, + ); + } + declarations.push("const answer = value30;"); + const { archive } = await createStrippedAsarAddon([ + { path: "app.js", contents: `const answer = { nested: ${expression} };` }, + { path: "growth.js", contents: declarations.join("\n") }, + ]); + const analyzed = await analyzeJavaScriptApplication({ + input_path: archive, + integrity_policy: "record-and-continue", + }); + if (!analyzed.ok) throw new Error("Expected explicit continuation"); + const result = javascriptApplicationAnalysisResultSchema.parse( + analyzed.value.normalized_result, + ); + expect(result.integrity_contradictions).toHaveLength(1); + expect(result.graph.coverage).toMatchObject({ + status: "partial", + omitted_count: null, + limits: expect.arrayContaining([ + expect.objectContaining({ + name: "javascript_semantic_primitive_candidates", + unit: "items", + }), + expect.objectContaining({ + name: "javascript_semantic_primitive_bytes", + unit: "bytes", + }), + ]), + }); +}); + +it("rejects a nested ASAR introduced after inventory as artifact drift", async () => { + const root = await createTestTempDirectory("rea-new-nested-asar-"); + const nestedSource = join(root, "nested-source"); + await mkdir(nestedSource); + await writeFile(join(root, "main.js"), "export const outer = true;\n"); + await writeFile( + join(nestedSource, "main.js"), + "export const nested = true;\n", + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + await createPackage(nestedSource, join(root, "new.asar")); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + await expect( + readJavaScriptArtifactFiles(reader, snapshot), + ).rejects.toMatchObject({ + reason: "integrity", + message: "Nested ASAR was not present in inventory: new.asar", + }); + } finally { + await reader.close(); + } +}); diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index 4910fd9f81..293a34a99e 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -9,6 +9,7 @@ import { afterEach, expect, it } from "vitest"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import type { ElectronActiveObservationPort } from "../../../src/application/javascript/ElectronActiveObservationPort.js"; @@ -22,6 +23,7 @@ import { } from "../../fixtures/fakeCdpBrowser.js"; import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; import { createElectronActiveObservationFixtureResult } from "../../../src/domain/javascript/electronActiveObservation.fixture.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; const browsers: FakeCdpBrowser[] = []; const resources: Array<{ close(): Promise }> = []; @@ -37,6 +39,52 @@ afterEach(async () => { ); }); +it("lets MCP JavaScript analysis continue only with recorded untrusted ASAR bytes", async () => { + const { archive, addon } = await createStrippedAsarAddon(); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "asar-integrity-policy", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const rejected = await client.callTool({ + name: "analyze_javascript_application", + arguments: { input_path: archive }, + }); + expect(parseMcpToolError(rejected)).toMatchObject({ + error: { + code: "artifact_integrity_mismatch", + remediation: { + action: expect.stringContaining("integrity_policy=record-and-continue"), + }, + }, + }); + + const continued = await client.callTool({ + name: "analyze_javascript_application", + arguments: { + input_path: archive, + integrity_policy: "record-and-continue", + }, + }); + expect(continued.isError).not.toBe(true); + expect(continued.structuredContent).toMatchObject({ + parameters: { integrity_policy: "record-and-continue" }, + normalized_result: { + integrity_contradictions: [ + { logical_path: addon, trust: "observed-untrusted" }, + ], + graph: { coverage: { status: "partial" } }, + }, + }); +}); + it("exposes endpoint-scoped Electron discovery and inspection as Evidence", async () => { const root = await createTestTempDirectory("rea-electron-mcp-"); temporary.push(root); diff --git a/tests/fixtures/strippedAsarAddon.ts b/tests/fixtures/strippedAsarAddon.ts new file mode 100644 index 0000000000..5ff76356cd --- /dev/null +++ b/tests/fixtures/strippedAsarAddon.ts @@ -0,0 +1,38 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { createPackageWithOptions } from "@electron/asar"; + +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +interface AdditionalAsarFile { + readonly path: string; + readonly contents: string; +} + +/** Create an Electron ASAR, then model a distro-stripped unpacked native addon. */ +export const createStrippedAsarAddon = async ( + additionalFiles: readonly AdditionalAsarFile[] = [], +) => { + const root = await createTestTempDirectory("rea-stripped-asar-addon-"); + const source = join(root, "source"); + const archive = join(root, "app.asar"); + const addon = "node_modules/btime/binding.node"; + const addonPath = join(source, "node_modules", "btime", "binding.node"); + const original = Buffer.from("upstream native addon bytes"); + const stripped = Buffer.from("stripped native addon"); + await mkdir(join(source, "node_modules", "btime"), { recursive: true }); + await writeFile(join(source, "main.js"), "module.exports = 'observed';\n"); + await writeFile(addonPath, original); + for (const file of additionalFiles) { + const path = join(source, file.path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, file.contents); + } + await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await writeFile( + join(`${archive}.unpacked`, "node_modules", "btime", "binding.node"), + stripped, + ); + return { root, archive, addon, original, stripped }; +};