360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
import { posix } from "node:path";
|
|
|
|
import type { ArtifactInventorySnapshot } from "./ArtifactInventory.js";
|
|
import type { ApplicationGraphEvidence } from "../domain/javascriptApplicationEvidenceSchemas.js";
|
|
import type { ApplicationNode } from "../domain/javascriptApplicationGraph.js";
|
|
import type { JavaScriptSourceRange } from "../domain/javascriptStaticAnalysisTypes.js";
|
|
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
|
|
import type {
|
|
JavaScriptArtifactFile,
|
|
JavaScriptArtifactFileSet,
|
|
} from "./JavaScriptArtifactFiles.js";
|
|
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
|
|
import {
|
|
artifactObservationEvidence,
|
|
astObservationEvidence,
|
|
completeReconstructionCoverage,
|
|
partialReconstructionCoverage,
|
|
staticInferenceEvidence,
|
|
unavailableAstEvidence,
|
|
} from "./JavaScriptArtifactGraphEvidence.js";
|
|
import type { JavaScriptArtifactReconstructionInput } from "./JavaScriptArtifactReconstructionInput.js";
|
|
|
|
/** Mutable graph-construction state scoped to one reconstruction. */
|
|
export interface JavaScriptArtifactGraphContext {
|
|
readonly accumulator: JavaScriptArtifactGraphAccumulator;
|
|
readonly snapshot: ArtifactInventorySnapshot;
|
|
readonly fileSet: JavaScriptArtifactFileSet;
|
|
readonly analysis: JavaScriptArtifactAnalysis;
|
|
readonly input: JavaScriptArtifactReconstructionInput;
|
|
readonly root: ApplicationNode;
|
|
readonly filesByPath: ReadonlyMap<string, JavaScriptArtifactFile>;
|
|
readonly fileNodes: Map<string, ApplicationNode>;
|
|
readonly assetNodes: Map<string, ApplicationNode>;
|
|
readonly moduleNodes: Map<string, ApplicationNode>;
|
|
readonly containerNodes: Map<string, ApplicationNode>;
|
|
}
|
|
|
|
/** Evidence coverage shape shared by reconstruction graph helpers. */
|
|
export type JavaScriptArtifactGraphCoverage =
|
|
ApplicationGraphEvidence["coverage"];
|
|
|
|
/** Direct artifact-containment edge values. */
|
|
export interface ContainsEdgeInput {
|
|
readonly source: ApplicationNode;
|
|
readonly target: ApplicationNode;
|
|
readonly file: JavaScriptArtifactFile;
|
|
readonly operation: string;
|
|
}
|
|
|
|
/** Exact AST containment edge values. */
|
|
export interface AstEdgeInput {
|
|
readonly source: ApplicationNode;
|
|
readonly target: ApplicationNode;
|
|
readonly file: JavaScriptArtifactFile;
|
|
readonly range: JavaScriptSourceRange;
|
|
readonly coverage: JavaScriptArtifactGraphCoverage;
|
|
readonly properties: Readonly<Record<string, unknown>>;
|
|
}
|
|
|
|
/** Inferred static relationship edge values. */
|
|
export interface InferenceEdgeInput extends AstEdgeInput {
|
|
readonly relation:
|
|
| "imports"
|
|
| "loads"
|
|
| "maps_to"
|
|
| "exposes"
|
|
| "calls"
|
|
| "persists_to";
|
|
}
|
|
|
|
/** Electron role identity and static discovery evidence. */
|
|
export interface RoleNodeInput {
|
|
readonly kind: "electron-main" | "electron-preload" | "electron-renderer";
|
|
readonly anchor: JavaScriptArtifactFile;
|
|
readonly declaredPath: string;
|
|
readonly resolvedPath: string | null;
|
|
readonly mechanism: string;
|
|
readonly range?: JavaScriptSourceRange;
|
|
readonly coverage?: JavaScriptArtifactGraphCoverage;
|
|
}
|
|
|
|
/** Resolution from an inferred Electron role to an artifact asset. */
|
|
export interface RoleAssetLinkInput {
|
|
readonly role: ApplicationNode;
|
|
readonly anchor: JavaScriptArtifactFile;
|
|
readonly path: string | null;
|
|
readonly range?: JavaScriptSourceRange;
|
|
readonly coverage?: JavaScriptArtifactGraphCoverage;
|
|
}
|
|
|
|
/** Explicit package, JavaScript, or source-map parse gap. */
|
|
export interface UnavailableParseInput {
|
|
readonly file: JavaScriptArtifactFile;
|
|
readonly asset: ApplicationNode;
|
|
readonly operation: string;
|
|
readonly limitation: string;
|
|
}
|
|
|
|
/** Add a direct artifact-containment edge. */
|
|
export const addArtifactContainsEdge = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: ContainsEdgeInput,
|
|
): void => {
|
|
context.accumulator.addEdge({
|
|
source_node_id: input.source.node_id,
|
|
target_node_id: input.target.node_id,
|
|
relation: "contains",
|
|
properties: { path: input.file.path },
|
|
evidence: artifactObservationEvidence({
|
|
sha256:
|
|
input.operation === "inventory-entry"
|
|
? input.file.container_sha256
|
|
: input.file.sha256,
|
|
path: input.file.path,
|
|
operation: input.operation,
|
|
coverage: completeReconstructionCoverage(),
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Add an exact AST containment edge. */
|
|
export const addAstContainsEdge = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: AstEdgeInput,
|
|
): void => {
|
|
context.accumulator.addEdge({
|
|
source_node_id: input.source.node_id,
|
|
target_node_id: input.target.node_id,
|
|
relation: "contains",
|
|
properties: input.properties,
|
|
evidence: astObservationEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
range: input.range,
|
|
operation: "map-bundler-structure",
|
|
coverage: input.coverage,
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Add an explicitly inferred static relationship edge. */
|
|
export const addStaticInferenceEdge = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: InferenceEdgeInput,
|
|
): void => {
|
|
context.accumulator.addEdge({
|
|
source_node_id: input.source.node_id,
|
|
target_node_id: input.target.node_id,
|
|
relation: input.relation,
|
|
properties: input.properties,
|
|
evidence: staticInferenceEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
range: input.range,
|
|
operation: "map-static-relationship",
|
|
coverage: input.coverage,
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Preserve a static parse or approval gap as an explicit unknown scope. */
|
|
export const addUnavailableStaticParseScope = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: UnavailableParseInput,
|
|
): void => {
|
|
const node = context.accumulator.addNode({
|
|
kind: "unknown",
|
|
identity: artifactLocalIdentity(
|
|
input.file.sha256,
|
|
"static-parse-scope",
|
|
`${input.operation}:${input.file.path}`,
|
|
),
|
|
observations: [
|
|
{
|
|
label: `${input.file.path} parse scope`,
|
|
properties: {
|
|
path: input.file.path,
|
|
operation: input.operation,
|
|
limitation: input.limitation,
|
|
},
|
|
evidence: unavailableAstEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
operation: input.operation,
|
|
coverage: partialReconstructionCoverage([], null, false),
|
|
limitation: input.limitation,
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
context.accumulator.addEdge({
|
|
source_node_id: input.asset.node_id,
|
|
target_node_id: node.node_id,
|
|
relation: "contains",
|
|
properties: { scope: "unavailable-static-analysis" },
|
|
evidence: artifactObservationEvidence({
|
|
sha256: input.file.sha256,
|
|
path: input.file.path,
|
|
operation: "associate-unavailable-parse-scope",
|
|
coverage: completeReconstructionCoverage(),
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Coverage for one bounded JavaScript AST analysis. */
|
|
export const javascriptAnalysisCoverage = (
|
|
analysis: NonNullable<
|
|
JavaScriptArtifactAnalysis["files"][number]["javascript"]
|
|
>,
|
|
input: JavaScriptArtifactReconstructionInput,
|
|
): JavaScriptArtifactGraphCoverage =>
|
|
analysis.parse_status === "complete"
|
|
? completeReconstructionCoverage(javaScriptAnalysisLimits(input))
|
|
: {
|
|
status: "partial",
|
|
truncated: analysis.parse_status === "truncated",
|
|
omitted_count: null,
|
|
limits: javaScriptAnalysisLimits(input),
|
|
};
|
|
|
|
/** Stable identity for a fact scoped to one exact artifact. */
|
|
export const artifactLocalIdentity = (
|
|
sha256: string,
|
|
namespace: string,
|
|
key: string,
|
|
) => ({
|
|
strategy: "artifact-local-key" as const,
|
|
stability: "artifact-version" as const,
|
|
artifact_sha256: sha256,
|
|
namespace: namespace.slice(0, 512),
|
|
key: key.slice(0, 4_096),
|
|
});
|
|
|
|
/** Create or merge an Electron role inferred from static syntax or metadata. */
|
|
export const createElectronRoleNode = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: RoleNodeInput,
|
|
): ApplicationNode =>
|
|
context.accumulator.addNode({
|
|
kind: input.kind,
|
|
identity: artifactLocalIdentity(
|
|
input.anchor.sha256,
|
|
"electron-role",
|
|
`${input.kind}:${input.resolvedPath ?? input.declaredPath}`,
|
|
),
|
|
observations: [
|
|
{
|
|
label: input.resolvedPath ?? input.declaredPath,
|
|
properties: {
|
|
declared_path: input.declaredPath,
|
|
resolved_path: input.resolvedPath,
|
|
mechanism: input.mechanism,
|
|
},
|
|
evidence: staticInferenceEvidence({
|
|
sha256: input.anchor.sha256,
|
|
path: input.anchor.path,
|
|
operation: "discover-electron-entrypoint",
|
|
coverage: input.coverage ?? completeReconstructionCoverage(),
|
|
...(input.range === undefined ? {} : { range: input.range }),
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
|
|
/** Resolve an Electron role to an inventoried asset when available. */
|
|
export const linkElectronRoleToAsset = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
input: RoleAssetLinkInput,
|
|
): void => {
|
|
if (input.path === null) return;
|
|
const asset =
|
|
context.assetNodes.get(input.path) ?? context.fileNodes.get(input.path);
|
|
if (asset === undefined) return;
|
|
context.accumulator.addEdge({
|
|
source_node_id: input.role.node_id,
|
|
target_node_id: asset.node_id,
|
|
relation: "maps_to",
|
|
properties: { resolved_path: input.path },
|
|
evidence: staticInferenceEvidence({
|
|
sha256: input.anchor.sha256,
|
|
path: input.anchor.path,
|
|
operation: "resolve-electron-entrypoint",
|
|
coverage: input.coverage ?? completeReconstructionCoverage(),
|
|
...(input.range === undefined ? {} : { range: input.range }),
|
|
}),
|
|
});
|
|
};
|
|
|
|
/** Deterministic key for one recovered module inside a bundle asset. */
|
|
export const moduleLookupKey = (path: string, moduleKey: string): string =>
|
|
`${path}\0${moduleKey}`;
|
|
|
|
/** Resolve a relative static specifier without escaping the artifact root. */
|
|
export const resolveArtifactPath = (
|
|
specifier: string,
|
|
sourcePath: string,
|
|
files: ReadonlyMap<string, JavaScriptArtifactFile>,
|
|
): string | null => {
|
|
const clean = specifier.split("#", 1)[0]?.split("?", 1)[0] ?? "";
|
|
if (clean === "" || /^[A-Za-z][A-Za-z+.-]*:/u.test(clean)) return null;
|
|
if (!clean.startsWith(".") && !clean.startsWith("/")) return null;
|
|
const candidate = posix.normalize(
|
|
clean.startsWith("/")
|
|
? clean.slice(1)
|
|
: posix.join(posix.dirname(sourcePath), clean),
|
|
);
|
|
if (candidate === ".." || candidate.startsWith("../")) return null;
|
|
for (const path of pathCandidates(candidate))
|
|
if (files.has(path)) return path;
|
|
return null;
|
|
};
|
|
|
|
/** Locate the recovered module that owns a static finding. */
|
|
export const sourceNodeFor = (
|
|
context: JavaScriptArtifactGraphContext,
|
|
path: string,
|
|
moduleKey: string | null,
|
|
): ApplicationNode | undefined =>
|
|
moduleKey === null
|
|
? undefined
|
|
: context.moduleNodes.get(moduleLookupKey(path, moduleKey));
|
|
|
|
const pathCandidates = (path: string): readonly string[] => [
|
|
path,
|
|
`${path}.js`,
|
|
`${path}.mjs`,
|
|
`${path}.cjs`,
|
|
`${path}.ts`,
|
|
`${path}.tsx`,
|
|
`${path}.node`,
|
|
posix.join(path, "index.js"),
|
|
posix.join(path, "index.ts"),
|
|
];
|
|
|
|
/** Named AST budgets committed to graph evidence coverage. */
|
|
export const javaScriptAnalysisLimits = (
|
|
input: JavaScriptArtifactReconstructionInput,
|
|
) => [
|
|
{
|
|
name: "max-ast-nodes",
|
|
value: input.limits.max_ast_nodes,
|
|
unit: "items" as const,
|
|
},
|
|
{
|
|
name: "max-findings",
|
|
value: input.limits.max_findings,
|
|
unit: "items" as const,
|
|
},
|
|
{
|
|
name: "max-modules",
|
|
value: input.limits.max_modules,
|
|
unit: "items" as const,
|
|
},
|
|
{
|
|
name: "max-parse-milliseconds",
|
|
value: input.limits.max_parse_milliseconds,
|
|
unit: "milliseconds" as const,
|
|
},
|
|
];
|