mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
* fix(javascript): mark resource-skipped files parse_status skipped Files above ~5 MB exhaust the worker heap during static analysis (or exceed the 8 MB static-checkpoint budget with a larger heap). They were inventoried with text included but never parsed, leaving parse_status null and no counter, so the summary read as 'nothing there' instead of 'not analyzed'. Report them as skipped: parse_status 'skipped' on the file observation and a skipped_javascript_files statistics counter. The analysis-failure reason keeps surfacing as a limitation entry. Fixes #3670 * fix(javascript): default skipped_javascript_files when reading old saved evidence Saved analysis evidence written before skipped_javascript_files existed failed validation against the strict reconstruction statistics schema, so users could not reuse old results for comparisons or runtime reconciliation. Accept the field's absence on read (defaulting to 0) while continuing to write it for new results. * fix(javascript): preserve unknown skipped count for legacy evidence The .default(0) gate fix fabricated a measured zero for saved pre-change evidence, so comparisons would receive a definitive "no files skipped" fact the producer never established. Use the codebase's exactOptional idiom instead: legacy reads accept absence and keep omission (unknown), while the producer still always writes the field (required by JavaScriptArtifactReconstructionResult). Tests: legacy payload now asserts the key stays absent/undefined; current-output test keeps asserting the producer-written counter. * fix(javascript): add required failure scope and status to graph fixture --------- Co-authored-by: Saim Shafique <sx4im@users.noreply.github.com>
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { access, lstat, realpath } from "node:fs/promises";
|
|
import { setImmediate } from "node:timers/promises";
|
|
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import {
|
|
AnalysisAccessDeniedError,
|
|
AnalysisInputError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js";
|
|
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
|
|
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
|
|
import type { JavaScriptSourceSelection } from "../../domain/javascript/javascriptSourceSelection.js";
|
|
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
|
import { parseOwnedJavaScriptApplicationAnalysisSteps } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
|
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
|
import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js";
|
|
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
import { analyzeAndAdoptJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
|
|
import {
|
|
isSkippedJavaScriptAnalysis,
|
|
javascriptArtifactAnalysisScopes,
|
|
} from "./JavaScriptArtifactAnalysisTypes.js";
|
|
import { createJavaScriptSourceAnalysis } from "../../artifacts/javascript/JavaScriptSourceAnalysis.js";
|
|
import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
|
|
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
|
|
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
|
|
import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js";
|
|
import {
|
|
javascriptArtifactReconstructionInputSchema,
|
|
type JavaScriptArtifactReconstructionInput,
|
|
} from "./JavaScriptArtifactReconstructionInput.js";
|
|
import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js";
|
|
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
|
|
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
|
|
import type { ProgressReporter } from "../ProgressReporter.js";
|
|
import {
|
|
checkpointJavaScriptAnalysis,
|
|
completeJavaScriptAnalysisSteps,
|
|
} from "./JavaScriptAnalysisControl.js";
|
|
|
|
/** Application-layer result retaining local diagnostics outside the canonical graph. */
|
|
export interface JavaScriptArtifactReconstructionResult {
|
|
readonly input_path: string;
|
|
readonly format: "asar" | "directory";
|
|
readonly root_artifact_sha256: string;
|
|
readonly inventory_manifest_id: string;
|
|
readonly inventory_graph_sha256: string;
|
|
readonly integrity_contradictions: ArtifactInventorySnapshot["integrity_contradictions"];
|
|
readonly source_selection?: JavaScriptSourceSelection;
|
|
readonly graph: JavaScriptApplicationGraph;
|
|
readonly semantic_graph: JavaScriptSemanticGraph;
|
|
readonly electron_summary: ElectronBoundarySummary;
|
|
readonly statistics: {
|
|
readonly relevant_files: number;
|
|
readonly nested_asar_containers: number;
|
|
readonly text_bytes_read: number;
|
|
readonly invalid_utf8_files: number;
|
|
readonly parsed_javascript_files: number;
|
|
readonly skipped_javascript_files: number;
|
|
readonly visited_ast_nodes: number;
|
|
readonly findings: number;
|
|
readonly modules: number;
|
|
readonly parse_failures: number;
|
|
};
|
|
readonly limitations: readonly string[];
|
|
}
|
|
|
|
/** Reconstruct one local ASAR or extracted directory without executing code. */
|
|
export const reconstructJavaScriptArtifact = async (
|
|
rawInput: unknown,
|
|
scope: ArtifactResourceScope,
|
|
signal?: AbortSignal,
|
|
progress?: ProgressReporter,
|
|
): Promise<JavaScriptArtifactReconstructionResult> =>
|
|
scope.run(async () => {
|
|
const reportPhase = async (
|
|
phase: string,
|
|
message: string,
|
|
): Promise<void> => {
|
|
await progress?.report({
|
|
phase,
|
|
completed: 0,
|
|
total: 1,
|
|
message,
|
|
});
|
|
await checkpointJavaScriptAnalysis(signal);
|
|
};
|
|
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
|
abortIfNeeded(signal);
|
|
const path = await resolveSelectedInput(input.input_path);
|
|
const format = await resolveFormat(path, input);
|
|
const snapshot = await scanCanonicalArtifactInventoryInScope(path, {
|
|
resourceScope: scope,
|
|
signal,
|
|
integrity: { mode: input.integrity_policy },
|
|
});
|
|
if (snapshot.manifest.root_format !== format)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
|
|
);
|
|
const reader = createReader(path, format);
|
|
const readerOwner = {
|
|
kind: "reader" as const,
|
|
reader,
|
|
resource: `JavaScript artifact reader for ${path}`,
|
|
};
|
|
let readerOpen = true;
|
|
let reconstructionOutcome:
|
|
| {
|
|
readonly kind: "completed";
|
|
readonly result: JavaScriptArtifactReconstructionResult;
|
|
}
|
|
| { readonly kind: "failed"; readonly cause: unknown };
|
|
try {
|
|
await reportPhase(
|
|
"read_javascript_artifacts",
|
|
"Reading inventoried JavaScript application sources",
|
|
);
|
|
const files = await readJavaScriptArtifactFiles(
|
|
reader,
|
|
snapshot,
|
|
scope,
|
|
signal,
|
|
input.source_paths,
|
|
);
|
|
// Release acquisition before analysis consumes these owned file facts.
|
|
const earlyCleanup = await scope.release(readerOwner);
|
|
readerOpen = false;
|
|
if (earlyCleanup.kind === "failed")
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
earlyCleanup.cause,
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
earlyCleanup.cause,
|
|
readerOwner.resource,
|
|
),
|
|
{ kind: "artifact-inventory", inventory: snapshot },
|
|
);
|
|
await reportPhase(
|
|
"parse_javascript_sources",
|
|
`Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`,
|
|
);
|
|
const semanticProjection = createJavaScriptSemanticGraphProjection({
|
|
...(input.max_semantic_nodes === undefined
|
|
? {}
|
|
: { totalNodes: input.max_semantic_nodes }),
|
|
...(input.max_semantic_nodes_per_file === undefined
|
|
? {}
|
|
: { fileNodes: input.max_semantic_nodes_per_file }),
|
|
});
|
|
const worker = createJavaScriptSourceAnalysis(
|
|
{
|
|
heapMb:
|
|
input.max_heap_mb ?? JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.heapMb,
|
|
timeoutMs:
|
|
input.analysis_timeout_ms ??
|
|
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.timeoutMs,
|
|
},
|
|
files.files.filter(
|
|
(file) => file.kind === "javascript" && file.text.included,
|
|
).length,
|
|
);
|
|
let outcome;
|
|
let cleanupFailure: AnalysisError | undefined;
|
|
try {
|
|
outcome = await analyzeAndAdoptJavaScriptArtifactFiles(
|
|
files,
|
|
worker,
|
|
semanticProjection,
|
|
async (file, completed, total) => {
|
|
abortIfNeeded(signal);
|
|
const ast =
|
|
javascriptArtifactAnalysisScopes[file.kind]?.authority ===
|
|
"ast-static-analysis";
|
|
await progress?.report({
|
|
phase: ast
|
|
? "parse_javascript_source"
|
|
: "process_javascript_artifact_metadata",
|
|
completed: 0,
|
|
total: 1,
|
|
message: `${ast ? "Parsing and projecting" : "Processing metadata for"} ${file.path} (${String(completed + 1)}/${String(total)})`,
|
|
});
|
|
abortIfNeeded(signal);
|
|
},
|
|
signal,
|
|
);
|
|
} finally {
|
|
try {
|
|
const cleanup = await scope.release({
|
|
kind: "javascript-source-analysis",
|
|
analysis: worker,
|
|
resource: `JavaScript source analyzer for ${path}`,
|
|
});
|
|
if (cleanup.kind === "failed") throw cleanup.cause;
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof AnalysisError)) throw cause;
|
|
cleanupFailure = cause;
|
|
}
|
|
}
|
|
const analysis = outcome.analysis;
|
|
let interruption = cleanupFailure ?? outcome.interruption;
|
|
// Source facts are owned now. Finish their projection cooperatively so a
|
|
// later cancellation can retain Evidence instead of discarding those facts.
|
|
const checkpointCompletedAnalysis = async (): Promise<void> => {
|
|
await setImmediate();
|
|
if (interruption == null && signal?.aborted === true)
|
|
interruption = new ArtifactOperationError(OPERATION, "cancelled");
|
|
};
|
|
const reportCompletedPhase = async (
|
|
phase: string,
|
|
message: string,
|
|
): Promise<void> => {
|
|
await checkpointCompletedAnalysis();
|
|
if (interruption == null)
|
|
await progress?.report({ phase, completed: 0, total: 1, message });
|
|
await checkpointCompletedAnalysis();
|
|
};
|
|
await reportCompletedPhase(
|
|
"build_javascript_application_graph",
|
|
"Constructing application and Electron boundary relationships",
|
|
);
|
|
const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps(
|
|
snapshot,
|
|
files,
|
|
analysis,
|
|
);
|
|
await reportCompletedPhase(
|
|
"seal_javascript_application_graph",
|
|
"Sealing the validated application graph",
|
|
);
|
|
const graph = await completeJavaScriptAnalysisSteps(
|
|
applicationGraphSteps,
|
|
);
|
|
await reportCompletedPhase(
|
|
"build_javascript_semantic_graph",
|
|
"Binding and validating static semantic relationships",
|
|
);
|
|
const semanticGraphSteps = semanticProjection.finishImmutableSteps(
|
|
snapshot.manifest.root_sha256,
|
|
graph,
|
|
);
|
|
await reportCompletedPhase(
|
|
"seal_javascript_semantic_graph",
|
|
"Validating and sealing the semantic graph",
|
|
);
|
|
const semanticGraph =
|
|
await completeJavaScriptAnalysisSteps(semanticGraphSteps);
|
|
await checkpointCompletedAnalysis();
|
|
const completed = {
|
|
input_path: path,
|
|
format,
|
|
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,
|
|
...(files.source_selection === undefined
|
|
? {}
|
|
: { source_selection: files.source_selection }),
|
|
graph,
|
|
semantic_graph: semanticGraph,
|
|
electron_summary: summarizeElectronBoundaries(
|
|
analysis,
|
|
new Set(
|
|
snapshot.occurrences
|
|
.filter(({ entry_kind }) => entry_kind === "directory")
|
|
.map(({ logical_path }) => logical_path),
|
|
),
|
|
),
|
|
statistics: {
|
|
relevant_files:
|
|
files.files.length + files.unavailable_relevant_files.length,
|
|
nested_asar_containers: files.containers.length,
|
|
text_bytes_read: files.text_bytes_read,
|
|
invalid_utf8_files: files.invalid_utf8_files,
|
|
parsed_javascript_files: analysis.files.filter(
|
|
({ javascript }) => javascript !== null,
|
|
).length,
|
|
skipped_javascript_files: analysis.files.filter(
|
|
isSkippedJavaScriptAnalysis,
|
|
).length,
|
|
visited_ast_nodes: analysis.visited_ast_nodes,
|
|
findings: analysis.findings,
|
|
modules: analysis.modules,
|
|
parse_failures: analysis.parse_failures,
|
|
},
|
|
limitations: analysis.limitations,
|
|
};
|
|
if (interruption !== null && interruption !== undefined) {
|
|
const { electron_summary: summary, ...application } = completed;
|
|
const partial = await completeJavaScriptAnalysisSteps(
|
|
parseOwnedJavaScriptApplicationAnalysisSteps({
|
|
...application,
|
|
summary,
|
|
limitations: completed.graph.limitations,
|
|
}),
|
|
);
|
|
const evidence =
|
|
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
|
input,
|
|
partial,
|
|
);
|
|
throw interruption.retainPartialObservation(evidence);
|
|
}
|
|
reconstructionOutcome = { kind: "completed", result: completed };
|
|
} catch (cause: unknown) {
|
|
reconstructionOutcome = { kind: "failed", cause };
|
|
}
|
|
if (readerOpen) {
|
|
const cleanupAttempt = await scope.release(readerOwner);
|
|
if (cleanupAttempt.kind === "failed") {
|
|
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
|
cleanupAttempt.cause,
|
|
readerOwner.resource,
|
|
);
|
|
const partialObservation: ArtifactInventoryPartialObservation = {
|
|
kind: "artifact-inventory",
|
|
inventory: snapshot,
|
|
};
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
reconstructionOutcome.kind === "failed"
|
|
? reconstructionOutcome.cause
|
|
: cleanupAttempt.cause,
|
|
cleanup,
|
|
partialObservation,
|
|
);
|
|
}
|
|
}
|
|
if (reconstructionOutcome.kind === "failed")
|
|
throw reconstructionOutcome.cause;
|
|
return reconstructionOutcome.result;
|
|
});
|
|
|
|
const OPERATION = "analyze_javascript_application";
|
|
|
|
/**
|
|
* Resolve the caller-selected input. A missing or unreadable selection is a
|
|
* caller or host-permission failure, not a damaged artifact.
|
|
*/
|
|
const resolveSelectedInput = async (inputPath: string): Promise<string> => {
|
|
try {
|
|
const path = await realpath(inputPath);
|
|
await access(path, constants.R_OK);
|
|
return path;
|
|
} catch (cause: unknown) {
|
|
const code =
|
|
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
|
if (code === "EACCES" || code === "EPERM")
|
|
throw new AnalysisAccessDeniedError(OPERATION, inputPath, code, {
|
|
cause,
|
|
});
|
|
if (code === "ENOENT" || code === "ENOTDIR")
|
|
throw new AnalysisInputError(OPERATION, { cause }, [
|
|
{
|
|
path: ["input_path"],
|
|
reason: "invalid_value",
|
|
message: `No file or directory exists at the selected input path (${code}): ${inputPath}`,
|
|
},
|
|
]);
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
/** The selected input's kind, checked against the caller's requested format. */
|
|
const resolveFormat = async (
|
|
path: string,
|
|
input: JavaScriptArtifactReconstructionInput,
|
|
): Promise<"asar" | "directory"> => {
|
|
const metadata = await lstat(path);
|
|
const observed = metadata.isDirectory()
|
|
? "directory"
|
|
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
|
|
? "asar"
|
|
: undefined;
|
|
if (observed === undefined)
|
|
throw new AnalysisUnsupportedTargetError(
|
|
OPERATION,
|
|
input.input_path,
|
|
"JavaScript application analysis accepts only a directory or an .asar file",
|
|
{
|
|
remediationAction:
|
|
"Select an extracted application directory, an app bundle, or an .asar file such as Contents/Resources/app.asar.",
|
|
},
|
|
);
|
|
if (input.format !== "auto" && input.format !== observed)
|
|
throw new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["format"],
|
|
reason: "invalid_value",
|
|
message: `Requested ${input.format} input, but the selected input is ${observed === "asar" ? "an .asar file" : "a directory"}: ${input.input_path}`,
|
|
expected: ["auto", observed],
|
|
},
|
|
]);
|
|
return observed;
|
|
};
|
|
|
|
const abortIfNeeded = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted === true)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"JavaScript artifact reconstruction cancelled",
|
|
);
|
|
};
|