fix(evidence): preserve subjects in derived workflows (#9492)

* fix(javascript): preserve verified trace evidence subjects

Co-authored-by: Felix <3937061056@qq.com>

* fix(evidence): preserve selected subject names in views

* fix(evidence): preserve subjects across related workflows

* fix(evidence): retain inventory projection subjects

* fix(managed): validate projected graph root identity

* style: format evidence and native command code

* fix(javascript): validate static graph root identity

* test(javascript): update feature trace graph expectations

---------

Co-authored-by: Felix <3937061056@qq.com>
This commit is contained in:
morlutoandFelix authored and GitHub committed 2026-11-09 08:12:17 +08:00
1 parent 4d7c6efe9a
commit 52fe342c73
28 files changed
+615 -151

No files matched your search

@@ -7,9 +7,9 @@ import type { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
createEvidence,
evidenceSubjectTarget,
type Evidence,
type EvidenceProvider,
type EvidenceSubjectTarget,
} from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ParsedInventorySet } from "../domain/artifactInventoryEvidence.js";
@@ -42,7 +42,6 @@ interface InventoryProjectionOptions<
readonly operation: string;
readonly predicateType: string;
readonly provider: EvidenceProvider;
readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"];
}
/** Resolve source inventory once, then wrap its family projection in Evidence. */
@@ -70,13 +69,7 @@ export const projectInventoryEvidence = <
const first = source.value.evidence[0];
return ok({
evidence: createEvidence(
first?.subject === null || first?.subject === undefined
? undefined
: {
path: first.subject.local_path,
sha256: result.root_sha256,
format: options.subjectFormat(first),
},
evidenceSubjectTarget(first?.subject ?? null),
options.provider,
{
predicateType: options.predicateType,
@@ -79,10 +79,32 @@ const applicationWithUnknowns = (
],
}),
);
const root = createJavaScriptApplicationNode({
kind: "artifact",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: APPLICATION_GRAPH_DIGESTS.asar,
},
observations: [
{
label: "managed root",
properties: {},
evidence: artifactEvidence(
APPLICATION_GRAPH_DIGESTS.asar,
"artifact-root",
),
},
],
});
const graph = createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: base.root_node_ids,
nodes: [...base.nodes, ...unknownNodes],
root_node_ids: [root.node_id],
nodes: [
...base.nodes.filter(({ node_id }) => node_id !== root.node_id),
...unknownNodes,
root,
],
edges: base.edges,
coverage: completeCoverage,
limitations: base.limitations,
@@ -21,6 +21,7 @@ import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { safeParseEvidence } from "../../domain/evidenceValidation.js";
import {
createEvidence,
evidenceSubjectTarget,
rebindEvidenceToTargetPath,
type Evidence,
type EvidenceSubjectTarget,
@@ -327,21 +328,11 @@ export const projectRetainedAnalysis = (
if (!retained.ok) return retained;
// Duplicate semantic records retain their original ledger metadata. Detail
// selection changes presentation, not the historical lookup or payload owner.
const subject = evidence.subject;
return projectResolvedAnalysisView(
{ source, view },
prepareAnalysisViewProjection(view),
retained.value,
subject === null
? undefined
: {
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
...(subject.architecture === null
? {}
: { architecture: subject.architecture }),
},
evidenceSubjectTarget(evidence.subject),
);
};
@@ -383,17 +374,7 @@ const createAnalysisViewEvidence = (
parent: Evidence,
result: AnalysisViewResult,
): Evidence => {
const target =
parent.subject === null
? undefined
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
format: parent.subject.format,
...(parent.subject.architecture === null
? {}
: { architecture: parent.subject.architecture }),
};
const target = evidenceSubjectTarget(parent.subject);
const nativeFunctionView =
result.kind === "native" ||
(result.kind === "summary" &&
@@ -2,6 +2,33 @@ import { expect, it, vi } from "vitest";
import { AndroidAnalysisService } from "./AndroidAnalysisService.js";
import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { createEvidence } from "../../domain/evidence.js";
import { ok } from "../../domain/result.js";
import { summarizeRetainedAndroidSearch } from "./AndroidSearchEvidenceSummary.js";
it("retains the selected subject in bounded search Evidence", () => {
const source = createEvidence(
{
path: "/fixture/app.apk",
sha256: "a".repeat(64),
format: "apk",
name: "Selected app",
},
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "search_android_classes",
parameters: {},
result: { matches: [] },
rawResult: { complete: true },
},
);
const result = summarizeRetainedAndroidSearch(source, { pages: 1 }, () =>
ok("added"),
);
if (!result.ok) throw result.error;
expect(result.value.subject).toEqual(source.subject);
expect(result.value.evidence_links).toContain(source.evidence_id);
});
it("retains selector correction issues before target acquisition", async () => {
const execute = vi.fn<AndroidAnalysisPort["execute"]>();
@@ -25,6 +25,5 @@ export const projectAndroidApplicationEvidence = (
operation: OPERATION,
predicateType: "rea.android-application-graph",
provider: ANDROID_APPLICATION_PROVIDER,
subjectFormat: () => "apk",
});
};
@@ -1,5 +1,9 @@
import type { EvidenceWriter } from "../investigation/InvestigationRecordPort.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
createEvidence,
evidenceSubjectTarget,
type Evidence,
} from "../../domain/evidence.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { ok, type Result } from "../../domain/result.js";
@@ -18,30 +22,16 @@ export const summarizeRetainedAndroidSearch = (
if (recordEvidence === undefined) return ok(evidence);
const recorded = recordEvidence(evidence);
if (!recorded.ok) return recorded;
const subject = evidence.subject;
return ok(
createEvidence(
subject === null
? undefined
: {
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
...(subject.architecture === null
? {}
: { architecture: subject.architecture }),
},
evidence.provider,
{
operation: evidence.operation,
parameters: evidence.parameters,
result: evidence.normalized_result,
rawResult: { ...summary, complete_evidence_id: evidence.evidence_id },
confidence: evidence.confidence,
limitations: evidence.limitations,
locations: evidence.locations,
evidenceLinks: [evidence.evidence_id],
},
),
createEvidence(evidenceSubjectTarget(evidence.subject), evidence.provider, {
operation: evidence.operation,
parameters: evidence.parameters,
result: evidence.normalized_result,
rawResult: { ...summary, complete_evidence_id: evidence.evidence_id },
confidence: evidence.confidence,
limitations: evidence.limitations,
locations: evidence.locations,
evidenceLinks: [evidence.evidence_id],
}),
);
};
@@ -25,6 +25,5 @@ export const projectAppleApplicationEvidence = (
operation: OPERATION,
predicateType: "rea.apple-application-graph",
provider: APPLE_APPLICATION_PROVIDER,
subjectFormat: (first) => first.subject?.format ?? "unknown",
});
};
@@ -25,6 +25,5 @@ export const projectHarmonyApplicationEvidence = (
operation: OPERATION,
predicateType: "rea.harmony-application-graph",
provider: HARMONY_APPLICATION_PROVIDER,
subjectFormat: (first) => first.subject?.format ?? "unknown",
});
};
@@ -3,7 +3,12 @@ import {
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
} from "../../domain/investigationProviders.js";
import { isImmutableEvidence, type Evidence } from "../../domain/evidence.js";
import {
isImmutableEvidence,
evidenceSubjectTarget,
type Evidence,
type EvidenceSubjectTarget,
} from "../../domain/evidence.js";
import { readEvidence } from "../../domain/evidenceValidation.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import {
@@ -35,6 +40,32 @@ export interface ApplicationGraphEvidenceSource {
readonly semanticGraph: JavaScriptSemanticGraph | null;
}
/** Carry a subject only when it identifies the graph's root artifact. */
export const applicationGraphSubjectTarget = (
source: ApplicationGraphEvidenceSource,
): EvidenceSubjectTarget | undefined => {
const subject = source.evidence.subject;
const staticRootObserved =
source.kind === "static-application" &&
source.graph.nodes.some(
(node) =>
node.kind === "artifact" &&
node.identity.strategy === "content-digest" &&
node.identity.sha256 === source.rootArtifactSha256 &&
node.observations.some(
({ evidence }) => evidence.extractor.operation === "inventory-root",
),
);
return evidenceSubjectTarget(
staticRootObserved ||
(source.kind === "managed-application" &&
subject?.format === "pe" &&
subject.digest.sha256 === source.rootArtifactSha256)
? subject
: null,
);
};
/** Caller-owned Evidence failed validation at the application graph boundary. */
export interface ApplicationGraphEvidenceInputFailure {
readonly issues: readonly AnalysisInputIssue[];
@@ -2,6 +2,7 @@ import {
createEvidence,
type Evidence,
type EvidenceObservation,
type EvidenceSubjectTarget,
} from "../../domain/evidence.js";
import type { ApplicationVersionComparisonResult } from "../../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import type { JavaScriptExportShapeComparisonResult } from "../../domain/javascript/javascriptExportShapeComparisonSchemas.js";
@@ -12,10 +13,11 @@ import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../../domain/investiga
/** Create derived Evidence for one bounded application-graph feature trace. */
export const createApplicationFeatureTraceEvidence = (
subject: EvidenceSubjectTarget | undefined,
parameters: EvidenceObservation["parameters"],
result: ApplicationFeatureTraceResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.application-feature-trace",
operation: "trace_application_feature",
parameters,
@@ -30,10 +32,11 @@ export const createApplicationFeatureTraceEvidence = (
/** Create derived Evidence for one bounded JavaScript semantic trace. */
export const createJavaScriptSemanticTraceEvidence = (
subject: EvidenceSubjectTarget | undefined,
parameters: EvidenceObservation["parameters"],
result: JavaScriptSemanticTraceResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
createEvidence(subject, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.javascript-semantic-trace",
operation: "trace_javascript_semantics",
parameters,
@@ -36,6 +36,7 @@ import { compareSourceToBundle } from "../../domain/javascript/sourceToBundleCom
import type { SourceToBundleComparisonResult } from "../../domain/javascript/sourceToBundleComparisonSchemas.js";
import {
applicationGraphEvidenceInputError,
applicationGraphSubjectTarget,
parseApplicationGraphEvidence,
parseNativeApplicationEvidence,
type ApplicationGraphEvidenceSource,
@@ -132,6 +133,7 @@ export const traceApplicationFeatureEvidence = (
direction: input.direction,
});
const evidence = createApplicationFeatureTraceEvidence(
applicationGraphSubjectTarget(source),
{
application_evidence_id: source.evidence.evidence_id,
native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id),
@@ -1,6 +1,15 @@
import { expect, it, vi } from "vitest";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
createEvidence,
parseEvidence,
rebindEvidenceToTargetPath,
type Evidence,
} from "../../domain/evidence.js";
import { ok } from "../../domain/result.js";
import { createJavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import { createJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import {
JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE,
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
@@ -14,18 +23,165 @@ import {
} from "./JavaScriptApplicationWorkflowService.js";
import { reconcileJavaScriptRuntimeEvidence } from "./JavaScriptRuntimeReconciliationService.js";
import { traceJavaScriptSemanticsEvidence } from "./JavaScriptSemanticTraceService.js";
import {
inspectAnalysisView,
projectRetainedAnalysis,
} from "../analysisView/AnalysisViewService.js";
const reference = ({ evidence_id }: Evidence) => ({
kind: "retained-evidence" as const,
evidence_id,
});
it("keeps a static trace subject unknown when legacy graph root evidence is omitted", () => {
const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
const result = javascriptApplicationAnalysisResultSchema.parse(
original.normalized_result,
);
const { graph_id: _graphId, ...graphInput } = result.graph;
const graph = createJavaScriptApplicationGraph({
...graphInput,
nodes: graphInput.nodes.filter(({ kind }) => kind !== "artifact"),
root_node_ids: graphInput.nodes
.filter(({ kind }) => kind === "javascript-asset")
.map(({ node_id }) => node_id),
edges: [],
});
const { graph_id: _semanticId, ...semanticInput } = result.semantic_graph;
const semanticGraph = createJavaScriptSemanticGraph({
...semanticInput,
application_graph_id: graph.graph_id,
});
if (original.subject === null)
throw new Error("Expected application subject");
const legacy = createEvidence(
{
path: original.subject.local_path,
sha256: original.subject.digest.sha256,
format: original.subject.format,
},
original.provider,
{
operation: original.operation,
predicateType: original.predicate_type,
parameters: original.parameters,
result: { ...result, graph, semantic_graph: semanticGraph },
confidence: "derived",
authority: "shipped-artifact",
},
);
const trace = traceApplicationFeatureEvidence({
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
application: legacy,
});
if (!trace.ok) throw trace.error;
expect(trace.value.evidence.subject).toBeNull();
});
const native = createEvidence(
{ path: "/example/addon.node", sha256: "e".repeat(64), format: "elf" },
{ id: "fixture", name: "Native fixture", version: "1" },
{ operation: "procedure_info", parameters: {}, result: {} },
);
it("preserves the verified application subject in both trace workflows", async () => {
const original = JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application;
if (original.subject === null)
throw new Error("Expected application subject");
const application = createEvidence(
{
path: original.subject.local_path,
sha256: original.subject.digest.sha256,
format: original.subject.format,
architecture: "x86_64",
name: "caller-selected-name",
},
original.provider,
{
operation: original.operation,
predicateType: original.predicate_type,
parameters: original.parameters,
result: original.normalized_result,
confidence: "derived",
authority: "shipped-artifact",
},
);
const feature = traceApplicationFeatureEvidence({
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
application,
});
const semantic = traceJavaScriptSemanticsEvidence({
application,
query: {
seed: { kind: "property", name: "unobserved" },
direction: "ownership",
},
});
for (const result of [feature, semantic]) {
if (!result.ok) throw result.error;
expect(result.value.evidence.subject).toEqual(application.subject);
expect(result.value.evidence.limitations).not.toContain(
"Artifact identity is unavailable for this observation.",
);
}
if (!feature.ok) throw feature.error;
const view = await inspectAnalysisView({
source: { kind: "inline", evidence: feature.value.evidence },
view: { kind: "summary" },
});
if (!view.ok) throw view.error;
expect(view.value.subject).toEqual(application.subject);
const selected = parseEvidence({
...feature.value.evidence,
subject: { ...feature.value.evidence.subject, name: "selected display" },
});
const retained = projectRetainedAnalysis(
selected,
{
recordEvidence: () => ok("added"),
evidenceById: () => feature.value.evidence,
},
{ kind: "summary" },
);
if (!retained.ok) throw retained.error;
expect(retained.value.subject?.name).toBe("selected display");
expect(
rebindEvidenceToTargetPath(feature.value.evidence, {
path: application.subject!.local_path,
sha256: "f".repeat(64),
format: application.subject!.format,
architecture: "x86_64",
name: "wrong artifact",
}),
).toBe(feature.value.evidence);
});
it("does not attribute a multi-source reconciliation graph to its envelope subject", () => {
const original = JAVASCRIPT_APPLICATION_VERSION_COMPARISON_EXAMPLE.right;
const reconciliation = createEvidence(
{ path: "/other/artifact", sha256: "f".repeat(64), format: "file" },
original.provider,
{
operation: original.operation,
predicateType: original.predicate_type,
parameters: original.parameters,
result: original.normalized_result,
confidence: "inferred",
authority: "analyst-inference",
evidenceLinks: original.evidence_links,
},
);
const result = traceApplicationFeatureEvidence({
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
application: reconciliation,
});
if (!result.ok) throw result.error;
expect(result.value.evidence.subject).toBeNull();
expect(result.value.evidence.limitations).toContain(
"Artifact identity is unavailable for this observation.",
);
});
it("resolves feature native arrays without changing source identity or unknowns", () => {
const input = {
...JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
@@ -8,6 +8,7 @@ import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
applicationGraphEvidenceInputError,
applicationGraphSubjectTarget,
parseApplicationGraphEvidence,
} from "./JavaScriptApplicationEvidenceGraph.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js";
@@ -63,6 +64,7 @@ export const traceJavaScriptSemanticsEvidence = (
};
const { allowed_relations, ...queryParameters } = input.query;
const evidence = createJavaScriptSemanticTraceEvidence(
applicationGraphSubjectTarget(source),
{
application_evidence_id: source.evidence.evidence_id,
query:
@@ -6,6 +6,7 @@ import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
createEvidence,
evidenceSubjectTarget,
type Evidence,
type EvidenceSubjectTarget,
} from "../../domain/evidence.js";
@@ -90,12 +91,5 @@ const subjectTarget = (
input.boundaries?.evidence.subject;
if (subject === undefined || subject === null || subject.format !== "pe")
return undefined;
return {
path: subject.local_path,
sha256: subject.digest.sha256,
format: "pe",
...(subject.architecture === null
? {}
: { architecture: subject.architecture }),
};
return evidenceSubjectTarget(subject);
};
@@ -2,6 +2,7 @@ import { createEvidence } from "../../domain/evidence.js";
import {
createJavaScriptApplicationGraph,
createJavaScriptApplicationNode,
createJavaScriptApplicationEdge,
} from "../../domain/javascript/javascriptApplicationGraph.js";
import { createWebTextArtifact } from "../../domain/webContentArtifact.js";
import {
@@ -63,11 +64,46 @@ const asset = createJavaScriptApplicationNode({
},
],
});
const root = createJavaScriptApplicationNode({
kind: "artifact",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: applicationSha256,
},
observations: [
{
label: "artifact root",
properties: { format: "directory" },
evidence: {
...graphEvidence,
artifact: {
available: true,
artifact_id: `art_${applicationSha256}`,
sha256: applicationSha256,
},
location: {
available: true,
value: { kind: "artifact-path", path: "artifact-root" },
},
extractor: { ...graphEvidence.extractor, operation: "inventory-root" },
},
},
],
});
const graph = createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids: [asset.node_id],
nodes: [asset],
edges: [],
root_node_ids: [root.node_id],
nodes: [asset, root],
edges: [
createJavaScriptApplicationEdge({
source_node_id: root.node_id,
target_node_id: asset.node_id,
relation: "contains",
properties: { path: "renderer.js" },
evidence: graphEvidence,
}),
],
coverage: completeCoverage,
limitations: [],
});
+8 -2
View File
@@ -883,7 +883,9 @@ it("projects typed module facts without source bodies and keeps source coverage
it("counts retained range observations including repeats and skips unavailable locations", () => {
const analysis = analysisViewJavaScriptAnalysis();
const original = analysis.graph.nodes[0];
const original = analysis.graph.nodes.find(
({ kind }) => kind === "javascript-asset",
);
const observed = original?.observations[0];
if (!original || !observed) throw new Error("missing module observation");
const range = {
@@ -929,6 +931,7 @@ it("counts retained range observations including repeats and skips unavailable l
...application,
nodes: [node],
root_node_ids: [node.node_id],
edges: [],
});
const parent = javascriptParent(
analysisViewBindJavaScriptGraphs(analysis, graph),
@@ -997,7 +1000,9 @@ it("validates typed module selectors without coercing null or empty paths", () =
it("keeps mixed observation support distinct instead of promoting a module to observed", () => {
const analysis = analysisViewJavaScriptAnalysis();
const original = analysis.graph.nodes[0];
const original = analysis.graph.nodes.find(
({ kind }) => kind === "javascript-asset",
);
const observed = original?.observations[0];
if (!original || !observed) throw new Error("missing module observation");
const inferred = {
@@ -1025,6 +1030,7 @@ it("keeps mixed observation support distinct instead of promoting a module to ob
...application,
nodes: [node],
root_node_ids: [node.node_id],
edges: [],
});
const result = projectAnalysisView(
javascriptParent(analysisViewBindJavaScriptGraphs(analysis, graph)),
@@ -1144,7 +1144,7 @@ export const sourceToBundlePageViewResultSchema = z.strictObject({
/** Compact retained feature-trace summary; pages carry the same trace context. */
export const featureTraceSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
artifact: z.null(),
artifact: artifactSchema.nullable(),
parent_operation: z.literal("trace_application_feature"),
summary: featureTraceSummarySchema,
});
@@ -1157,7 +1157,7 @@ const featureTracePage = <
...viewResultBase,
...featureTraceViewContextSchema.shape,
kind: z.literal("feature-trace"),
artifact: z.null(),
artifact: artifactSchema.nullable(),
parent_operation: z.literal("trace_application_feature"),
view: featureTraceViewCollections[collection].request,
items: featureTraceViewCollections[collection].rows,
+1 -1
View File
@@ -317,7 +317,7 @@ export const projectFeatureTraceView = (
parent_evidence_id: parent.evidenceId,
parent_operation: OPERATION,
parent_digest: parent.evidenceId.slice(3),
artifact: null,
artifact: parent.artifact ?? null,
limitations: boundedViewMessages(
limitations,
parent.evidenceId,
+40 -17
View File
@@ -184,16 +184,40 @@ export interface EvidenceSubjectTarget {
readonly sha256: string;
readonly format: z.infer<typeof subjectSchema>["format"];
readonly architecture?: BinaryArchitecture;
/** Preserve an existing Evidence subject's display name when deriving observations. */
readonly name?: string;
}
/** Preserve the complete subject when deriving a new Evidence observation. */
export const evidenceSubjectTarget = (
subject: Evidence["subject"],
): EvidenceSubjectTarget | undefined =>
subject === null
? undefined
: {
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
name: subject.name,
...(subject.architecture === null
? {}
: { architecture: subject.architecture }),
};
const sameEvidenceSubjectTargetIdentity = (
subject: EvidenceSubjectTarget,
target: EvidenceSubjectTarget,
): boolean =>
subject.sha256 === target.sha256 &&
subject.format === target.format &&
(subject.architecture ?? null) === (target.architecture ?? null);
/** Rebind a matching immutable artifact subject to the caller's selected path. */
export const rebindEvidenceSubjectTargetPath = (
subject: EvidenceSubjectTarget,
target: EvidenceSubjectTarget,
): EvidenceSubjectTarget =>
subject.sha256 === target.sha256 &&
subject.format === target.format &&
(subject.architecture ?? null) === (target.architecture ?? null)
sameEvidenceSubjectTargetIdentity(subject, target)
? { ...subject, path: target.path }
: subject;
@@ -324,21 +348,18 @@ export const rebindEvidenceToTargetPath = (
): Evidence => {
const subject = evidence.subject;
if (subject === null) return evidence;
const rebound = rebindEvidenceSubjectTargetPath(
{
path: subject.local_path,
sha256: subject.digest.sha256,
format: subject.format,
...(subject.architecture === null
? {}
: { architecture: subject.architecture }),
},
target,
);
if (rebound.path === subject.local_path) return evidence;
if (
!sameEvidenceSubjectTargetIdentity(evidenceSubjectTarget(subject)!, target)
)
return evidence;
if (
target.path === subject.local_path &&
(target.name === undefined || target.name === subject.name)
)
return evidence;
const reboundSubject = subjectSchema.parse({
...subject,
name: basename(target.path) || "artifact",
name: target.name ?? (basename(target.path) || "artifact"),
local_path: target.path,
});
const reboundEvidence = { ...evidence, subject: reboundSubject };
@@ -466,7 +487,9 @@ export const evidenceEnvelopeForAdmission = (
target === undefined
? null
: {
name: basename(target.path) || "artifact",
name:
("name" in target ? target.name : undefined) ??
(basename(target.path) || "artifact"),
digest: { sha256: target.sha256 },
format: target.format,
architecture: target.architecture ?? null,
@@ -132,6 +132,27 @@ function* checkApplicationBindingsSteps(
try {
for (const node of result.graph.nodes) {
applicationNodeIds.add(node.node_id);
if (
node.kind === "artifact" &&
(node.identity.strategy !== "content-digest" ||
node.identity.sha256 !== result.root_artifact_sha256)
) {
for (const observation of node.observations) {
if (observation.evidence.extractor.operation === "inventory-root") {
context.addIssue({
code: "custom",
path: ["root_artifact_sha256"],
message:
"Application artifact root must match the declared root SHA-256",
});
break;
}
if (++examined === 4096) {
examined = 0;
yield;
}
}
}
if (++examined === 4096) {
examined = 0;
yield;
+59 -42
View File
@@ -94,48 +94,65 @@ const managedGraphRequestSchema = getEvidenceInputContract(
).frameSchema;
/** Managed-code projection result containing a validated application graph. */
export const managedApplicationGraphResultSchema = z.strictObject({
projection_id: prefixedDigestSchema("magp"),
root_artifact_sha256: digestSchema,
source_evidence: z.strictObject({
managed_artifact_evidence_id: evidenceIdSchema.nullable(),
managed_members_evidence_id: evidenceIdSchema.nullable(),
managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(),
}),
summary: z.strictObject({
graph_nodes: z.int().min(0),
graph_edges: z.int().min(0),
assemblies: z.int().min(0),
modules: z.int().min(0),
types: z.int().min(0),
methods: z.int().min(0),
fields: z.int().min(0),
pinvoke_imports: z.int().min(0),
native_implementations: z.int().min(0),
// Graphs projected before call and field-access edges existed omit these
// counters; null means those relations were not projected, not zero.
calls: z
.int()
.min(0)
.nullable()
.default(null)
.describe("calls edges; null for graphs projected before calls edges"),
field_accesses: z
.int()
.min(0)
.nullable()
.default(null)
.describe(
"accesses edges; null for graphs projected before accesses edges",
),
}),
graph: javascriptApplicationGraphSchema,
coverage: z.strictObject({
status: z.enum(["complete-within-inputs", "partial"]),
}),
evidence_links: z.array(evidenceIdSchema).min(1).max(3),
limitations: z.array(textSchema),
});
export const managedApplicationGraphResultSchema = z
.strictObject({
projection_id: prefixedDigestSchema("magp"),
root_artifact_sha256: digestSchema,
source_evidence: z.strictObject({
managed_artifact_evidence_id: evidenceIdSchema.nullable(),
managed_members_evidence_id: evidenceIdSchema.nullable(),
managed_native_boundaries_evidence_id: evidenceIdSchema.nullable(),
}),
summary: z.strictObject({
graph_nodes: z.int().min(0),
graph_edges: z.int().min(0),
assemblies: z.int().min(0),
modules: z.int().min(0),
types: z.int().min(0),
methods: z.int().min(0),
fields: z.int().min(0),
pinvoke_imports: z.int().min(0),
native_implementations: z.int().min(0),
// Graphs projected before call and field-access edges existed omit these
// counters; null means those relations were not projected, not zero.
calls: z
.int()
.min(0)
.nullable()
.default(null)
.describe("calls edges; null for graphs projected before calls edges"),
field_accesses: z
.int()
.min(0)
.nullable()
.default(null)
.describe(
"accesses edges; null for graphs projected before accesses edges",
),
}),
graph: javascriptApplicationGraphSchema,
coverage: z.strictObject({
status: z.enum(["complete-within-inputs", "partial"]),
}),
evidence_links: z.array(evidenceIdSchema).min(1).max(3),
limitations: z.array(textSchema),
})
.superRefine((result, context) => {
const [rootId] = result.graph.root_node_ids;
const root = result.graph.nodes.find(({ node_id }) => node_id === rootId);
if (
result.graph.root_node_ids.length !== 1 ||
root?.kind !== "artifact" ||
root.identity.strategy !== "content-digest" ||
root.identity.sha256 !== result.root_artifact_sha256
)
context.addIssue({
code: "custom",
path: ["root_artifact_sha256"],
message:
"Managed graph root must identify the declared root artifact SHA-256.",
});
});
export type ManagedApplicationGraphResult = z.infer<
typeof managedApplicationGraphResultSchema
@@ -771,15 +771,16 @@ describe("application workflow CLI parity", () => {
expect(traced).toMatchObject({
operation: "trace_application_feature",
predicate_type: "rea.application-feature-trace",
subject: JAVASCRIPT_FEATURE_TRACE_EXAMPLE.application.subject,
normalized_result: {
seed: { kind: "module", value: "renderer.js", match: "exact" },
summary: {
matched_seeds: 1,
traced_nodes: 1,
traced_edges: 0,
traced_nodes: 2,
traced_edges: 1,
terminal_paths: 0,
native_handoffs: 0,
observed_facts: 1,
observed_facts: 3,
inferred_facts: 0,
unknown_facts: 0,
unavailable_facts: 0,
@@ -809,7 +810,7 @@ describe("application workflow CLI parity", () => {
predicate_type: "rea.application-version-comparison",
normalized_result: {
summary: {
unchanged: 0,
unchanged: 1,
added: 2,
removed: 0,
changed: 1,
@@ -852,7 +853,7 @@ describe("application workflow CLI parity", () => {
reference_inventory_state: "complete",
application_graph_status: "complete",
retained_source_files: 1,
retained_application_nodes: 1,
retained_application_nodes: 2,
},
},
});
@@ -890,6 +891,7 @@ describe("application workflow CLI parity", () => {
expect(traced).toMatchObject({
operation: "trace_javascript_semantics",
predicate_type: "rea.javascript-semantic-trace",
subject: analyzed.value.subject,
normalized_result: {
source_evidence_id: analyzed.value.evidence_id,
source_graph_id: result.semantic_graph.graph_id,
@@ -936,6 +938,7 @@ it("traces all endpoint carriers and their response consumers through CLI and MC
const cliEvidence = parseEvidence(
await runCliWithRequestFile(root, "trace-javascript-semantics", input),
);
expect(cliEvidence.subject).toEqual(application.subject);
const mcpResponse = await client.callTool({
name: "trace_javascript_semantics",
arguments: input,
@@ -1920,6 +1923,11 @@ const runCliWithRequestFile = async (
/** The CLI keeps complete trace JSON; the shared view owner selects from the saved file. */
const assertTraceFileView = async (root: string, traced: unknown) => {
const parent = parseEvidence(traced);
if (parent.subject === null) throw new Error("Expected trace subject");
const artifact = {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
};
const tracePath = join(root, "trace.json");
await writeFile(tracePath, JSON.stringify(parent));
const source = { kind: "evidence-file", path: tracePath };
@@ -1934,7 +1942,8 @@ const assertTraceFileView = async (root: string, traced: unknown) => {
kind: "summary",
parent_evidence_id: parent.evidence_id,
parent_operation: "trace_application_feature",
summary: { summary: { matched_seeds: 1, traced_nodes: 1 } },
artifact,
summary: { summary: { matched_seeds: 1, traced_nodes: 2 } },
},
});
expect(
@@ -1949,8 +1958,9 @@ const assertTraceFileView = async (root: string, traced: unknown) => {
).toMatchObject({
normalized_result: {
kind: "feature-trace",
artifact,
items: [{ seed: true, observation_count: 1 }],
coverage: { total: 1, exhausted: true },
coverage: { total: 2, exhausted: false },
},
});
// Without a session ledger the CLI cannot retain a parent for a selected view.
@@ -20,6 +20,7 @@ import {
import { projectAndroidApplicationEvidence } from "../../../../src/application/android/AndroidApplicationService.js";
import { androidApplicationProjectionResultSchema } from "../../../../src/domain/android/androidApplication.js";
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
describe("Android application projection", () => {
it("projects deterministic APK components and explicit bridge hypotheses", async () => {
@@ -172,6 +173,23 @@ describe("Android application projection", () => {
});
});
it("preserves the selected inventory subject through application projection", async () => {
const root = await createTestTempDirectory("rea-android-subject-");
const path = join(root, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("AndroidManifest.xml", new TextReader("manifest"));
await writeFile(path, await writer.close());
const inventory = await inventoryApplicationPackage(path);
const selected = parseEvidence({
...inventory,
subject: { ...inventory.subject, name: "Selected application" },
});
const result = projectAndroidApplicationEvidence({
inventory_evidence: [selected],
});
expect(requireSuccessfulProjection(result).subject).toEqual(selected.subject);
});
it("projects APK v1 metadata candidates at any META-INF depth", async () => {
const root = await createTestTempDirectory("rea-android-v1-metadata-");
const path = join(root, "NestedSigning.apk");
@@ -9,7 +9,9 @@ import {
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js";
@@ -45,6 +47,40 @@ cliTest(
});
expect(cliResponse.exitCode, cliResponse.stderr).toBe(0);
const cliEvidence = parseEvidence(cliResponse.json);
const parsed = javascriptApplicationAnalysisResultSchema.parse(
cliEvidence.normalized_result,
);
const { graph_id: _id, ...semantic } = parsed.semantic_graph;
const foreignSha = "f".repeat(64);
const forged = createEvidence(
{ path: parsed.input_path, sha256: foreignSha, format: parsed.format },
cliEvidence.provider,
{
operation: cliEvidence.operation,
predicateType: cliEvidence.predicate_type,
parameters: cliEvidence.parameters,
confidence: "derived",
authority: "shipped-artifact",
result: {
...parsed,
root_artifact_sha256: foreignSha,
semantic_graph: createJavaScriptSemanticGraph({
...semantic,
root_artifact_sha256: foreignSha,
}),
},
},
);
expect(parseApplicationGraphEvidence(forged)).toMatchObject({
ok: false,
error: {
issues: expect.arrayContaining([
expect.objectContaining({
path: ["normalized_result", "root_artifact_sha256"],
}),
]),
},
});
for (const command of ["analyze", "analyze-javascript-application"]) {
const relativeResponse = await cli.run({
@@ -163,7 +163,21 @@ async function runInlineWorkflowScenarios(
expect(tracedInline.structuredContent).toMatchObject({
normalized_result: {
seed: { kind: "module", value: "renderer.js", match: "exact" },
summary: { matched_seeds: 1, traced_nodes: 1, unknown_facts: 0 },
summary: {
matched_seeds: 1,
traced_nodes: 2,
traced_edges: 1,
unknown_facts: 0,
},
graph: {
nodes: expect.arrayContaining([
expect.objectContaining({ kind: "artifact" }),
expect.objectContaining({ kind: "javascript-asset" }),
]),
edges: expect.arrayContaining([
expect.objectContaining({ relation: "contains" }),
]),
},
coverage: {
status: "complete-within-source",
source_graph_status: "complete",
@@ -143,7 +143,13 @@ it.each(["full", "compact"] as const)(
expect(summary.evidence.normalized_result).toMatchObject({
kind: "summary",
parent_operation: "trace_application_feature",
artifact: null,
artifact:
parent.subject === null
? null
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
},
summary: {
trace_id: full.trace_id,
source_evidence_id: full.source_evidence_id,
@@ -35,7 +35,7 @@ const scenarios = [
name: "trace_application_feature",
input: JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
sourceIds: [application.evidence_id],
facts: { summary: { matched_seeds: 1, traced_nodes: 1 } },
facts: { summary: { matched_seeds: 1, traced_nodes: 2, traced_edges: 1 } },
},
{
name: "trace_javascript_semantics",
@@ -273,6 +273,85 @@ describe("managed application graph projection", () => {
});
});
it("preserves only a managed graph's matching subject in feature traces", () => {
const { artifactEvidence } = createManagedInteropEvidence();
const selectedArtifact = parseEvidence({
...artifactEvidence,
subject: { ...artifactEvidence.subject, name: "selected managed image" },
});
const projected = projectManagedApplicationGraphEvidence({
managed_artifact: selectedArtifact,
});
if (!projected.ok) throw projected.error;
const parsed = parseEvidence(projected.value);
expect(parsed.subject).toEqual(selectedArtifact.subject);
const query = {
seed: {
kind: "string" as const,
value: "MessageBoxW",
match: "exact" as const,
case_sensitive: true,
},
direction: "incoming" as const,
};
const trace = traceApplicationFeatureEvidence({
application: parsed,
...query,
});
if (!trace.ok) throw trace.error;
expect(trace.value.evidence.subject).toEqual(selectedArtifact.subject);
const foreign = createEvidence(
{ path: "/other.exe", sha256: "f".repeat(64), format: "pe" },
parsed.provider,
{
operation: parsed.operation,
predicateType: parsed.predicate_type,
parameters: parsed.parameters,
result: parsed.normalized_result,
confidence: "inferred",
authority: "analyst-inference",
evidenceLinks: parsed.evidence_links,
},
);
const foreignTrace = traceApplicationFeatureEvidence({
application: foreign,
...query,
});
if (!foreignTrace.ok) throw foreignTrace.error;
expect(foreignTrace.value.evidence.subject).toBeNull();
const contradictory = createEvidence(
{ path: "/other.exe", sha256: "f".repeat(64), format: "pe" },
parsed.provider,
{
operation: parsed.operation,
predicateType: parsed.predicate_type,
parameters: parsed.parameters,
result: {
...(parsed.normalized_result as Record<string, unknown>),
root_artifact_sha256: "f".repeat(64),
},
confidence: "inferred",
authority: "analyst-inference",
evidenceLinks: parsed.evidence_links,
},
);
const contradictoryTrace = traceApplicationFeatureEvidence({
application: contradictory,
...query,
});
expect(contradictoryTrace).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: expect.arrayContaining([
expect.objectContaining({
path: ["application", "normalized_result", "root_artifact_sha256"],
}),
]),
},
});
});
describe("managed application graph request", () => {
it("accepts any nonempty set of inline sources and rejects duplicates", () => {
const evidence = MANAGED_APPLICATION_GRAPH_EXAMPLE.managed_members;