feat(artifacts): project mobile application inventories

This commit is contained in:
morluto committed 2026-07-20 11:31:35 +00:00
1 parent 02cb20b546
commit d36b385378
10 files changed
+1350

No files matched your search

@@ -0,0 +1,27 @@
import type { AnalysisError } from "../domain/errors.js";
import type { Evidence } from "../domain/evidence.js";
import {
androidApplicationProjectionInputSchema,
projectAndroidApplication,
} from "../domain/androidApplication.js";
import type { Result } from "../domain/result.js";
import { ANDROID_APPLICATION_PROVIDER } from "./InvestigationProviders.js";
import { projectInventoryEvidence } from "./InventoryProjectionEvidence.js";
const OPERATION = "project_android_application_graph" as const;
/** Project authenticated APK inventory Evidence into Android application Evidence. */
export const projectAndroidApplicationEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
return projectInventoryEvidence({
rawInput,
schema: androidApplicationProjectionInputSchema,
project: projectAndroidApplication,
operation: OPERATION,
predicateType: "rea.android-application-graph/v1",
provider: ANDROID_APPLICATION_PROVIDER,
subjectFormat: () => "apk",
protocolError: "Android application projection produced an invalid result",
});
};
@@ -0,0 +1,27 @@
import type { AnalysisError } from "../domain/errors.js";
import type { Evidence } from "../domain/evidence.js";
import {
appleApplicationProjectionInputSchema,
projectAppleApplication,
} from "../domain/appleApplication.js";
import type { Result } from "../domain/result.js";
import { APPLE_APPLICATION_PROVIDER } from "./InvestigationProviders.js";
import { projectInventoryEvidence } from "./InventoryProjectionEvidence.js";
const OPERATION = "project_apple_application_graph" as const;
/** Project authenticated IPA inventory Evidence into Apple application evidence. */
export const projectAppleApplicationEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
return projectInventoryEvidence({
rawInput,
schema: appleApplicationProjectionInputSchema,
project: projectAppleApplication,
operation: OPERATION,
predicateType: "rea.apple-application-graph/v1",
provider: APPLE_APPLICATION_PROVIDER,
subjectFormat: () => "ipa",
protocolError: "Apple application projection produced an invalid result",
});
};
@@ -0,0 +1,91 @@
import { z } from "zod";
import {
AnalysisInputError,
AnalysisProtocolError,
type AnalysisError,
} from "../domain/errors.js";
import {
createEvidence,
type Evidence,
type EvidenceProvider,
type EvidenceSubjectTarget,
} from "../domain/evidence.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
interface InventoryProjectionInput {
readonly inventory_evidence: readonly Evidence[];
readonly limits: unknown;
}
interface InventoryProjectionResult {
readonly root_sha256: string;
readonly source_evidence_ids: readonly string[];
readonly limitations: readonly string[];
}
interface InventoryProjectionOptions<
Input extends InventoryProjectionInput,
Output extends InventoryProjectionResult,
> {
readonly rawInput: unknown;
readonly schema: z.ZodType<Input>;
readonly project: (input: Input) => Output;
readonly operation: string;
readonly predicateType: string;
readonly provider: EvidenceProvider;
readonly subjectFormat: (first: Evidence) => EvidenceSubjectTarget["format"];
readonly protocolError: string;
}
/** Parse one inventory projection and wrap its deterministic result in Evidence v2. */
export const projectInventoryEvidence = <
Input extends InventoryProjectionInput,
Output extends InventoryProjectionResult,
>(
options: InventoryProjectionOptions<Input, Output>,
): Result<Evidence, AnalysisError> => {
const parsed = options.schema.safeParse(options.rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(options.operation, { cause: parsed.error }),
);
try {
const result = options.project(parsed.data);
const first = parsed.data.inventory_evidence[0];
return ok(
createEvidence(
first?.subject === null || first?.subject === undefined
? undefined
: {
path: first.subject.local_path,
sha256: result.root_sha256,
format: options.subjectFormat(first),
},
options.provider,
{
predicateType: options.predicateType,
operation: options.operation,
parameters: {
inventory_evidence_ids: [...result.source_evidence_ids],
limits: jsonValueSchema.parse(parsed.data.limits),
},
result: jsonValueSchema.parse(result) as JsonValue,
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.source_evidence_ids,
},
),
);
} catch (cause: unknown) {
return err(
cause instanceof TypeError || cause instanceof z.ZodError
? new AnalysisInputError(options.operation, { cause })
: new AnalysisProtocolError(options.protocolError, { cause }),
);
}
};
+14
View File
@@ -39,6 +39,20 @@ export const MANAGED_WORKFLOW_PROVIDER = {
version: "1",
} as const;
/** Provider identity for deterministic Apple application projections. */
export const APPLE_APPLICATION_PROVIDER = {
id: "rea-apple-application",
name: "REA Apple application workflows",
version: "1",
} as const;
/** Provider identity for deterministic Android application projections. */
export const ANDROID_APPLICATION_PROVIDER = {
id: "rea-android-application",
name: "REA Android application workflows",
version: "1",
} as const;
/** Provider identity for deterministic static JavaScript application analysis. */
export const JAVASCRIPT_APPLICATION_PROVIDER = {
id: "rea-javascript-application",
+283
View File
@@ -0,0 +1,283 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { evidenceSchema } from "./evidence.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
const pathSchema = z.string().min(1).max(4_096);
const componentSchema = z.strictObject({
path: pathSchema,
artifact_id: z.string().regex(/^art_[a-f0-9]{64}$/u),
sha256: digestSchema,
format: z.string().min(1).max(100),
});
/** Authenticated APK inventory pages projected as one Android application. */
export const androidApplicationProjectionInputSchema = z.strictObject({
inventory_evidence: z.array(evidenceSchema).min(1).max(100),
limits: z
.strictObject({
max_components: z.number().int().min(1).max(10_000).default(1_000),
})
.default({ max_components: 1_000 }),
});
/** Deterministic, execution-free Android application inventory projection. */
export const androidApplicationProjectionResultSchema = z.strictObject({
schema_version: z.literal(1),
projection_id: z.string().regex(/^adp_[a-f0-9]{64}$/u),
root_sha256: digestSchema,
root_format: z.literal("apk"),
source_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
components: z.strictObject({
manifests: z.array(componentSchema),
resources: z.array(componentSchema),
dex: z.array(componentSchema),
jvm_classes: z.array(componentSchema),
native_libraries: z.array(componentSchema),
javascript: z.array(componentSchema),
signing: z.array(componentSchema),
}),
runtime_families: z.array(
z.enum([
"dalvik-art",
"java-kotlin",
"native",
"javascript",
"react-native",
"flutter",
"unity",
]),
),
bridge_candidates: z.array(
z.strictObject({
managed_path: pathSchema,
native_path: pathSchema,
basis: z.enum([
"managed-and-native-content",
"jni-library-convention",
"react-native-convention",
"flutter-convention",
"unity-convention",
]),
}),
),
coverage: z.strictObject({
status: z.enum(["complete-within-inventory", "partial", "truncated"]),
inventory_complete: z.boolean(),
omitted_components: z.number().int().min(0),
omitted_bridge_candidates: z.number().int().min(0),
}),
limitations: z.array(z.string().min(1).max(4_096)).max(100),
});
export type AndroidApplicationProjectionInput = z.infer<
typeof androidApplicationProjectionInputSchema
>;
export type AndroidApplicationProjectionResult = z.infer<
typeof androidApplicationProjectionResultSchema
>;
type Component = z.infer<typeof componentSchema>;
/** Project exact APK paths and hashes without decoding or executing target code. */
export const projectAndroidApplication = (
input: AndroidApplicationProjectionInput,
): AndroidApplicationProjectionResult => {
const parsed = androidApplicationProjectionInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
);
if (inventory.manifest.root_format !== "apk")
throw new TypeError("Android application projection requires APK Evidence");
const nodes = new Map(
inventory.nodes.map((node) => [node.artifact_id, node]),
);
const all = inventory.occurrences
.filter(
(occurrence) =>
occurrence.artifact_id !== null && occurrence.logical_path !== ".",
)
.map((occurrence) => {
const node = nodes.get(occurrence.artifact_id ?? "");
if (node === undefined)
throw new TypeError(
"Android application occurrence has no artifact node",
);
return {
path: occurrence.logical_path,
artifact_id: node.artifact_id,
sha256: node.sha256,
format: node.format,
} satisfies Component;
});
const classified = classify(all);
const components = retain(classified, parsed.limits.max_components);
const omittedComponents = count(classified) - count(components);
const bridgeProjection = bridgeCandidates(
[...components.dex, ...components.jvm_classes],
components.native_libraries,
parsed.limits.max_components,
);
const limitations = [
...(!inventory.complete
? ["Source inventory pages are incomplete; absence is unknown."]
: []),
...(omittedComponents > 0
? [
`${String(omittedComponents)} component observations were omitted by the projection limit.`,
]
: []),
...(bridgeProjection.omitted > 0
? [
`${String(bridgeProjection.omitted)} bridge hypotheses were omitted by the projection limit.`,
]
: []),
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
"Bridge candidates are path-based hypotheses, not decoded JNI declarations or observed runtime calls.",
];
const withoutId = {
schema_version: 1 as const,
root_sha256: inventory.manifest.root_sha256,
root_format: "apk" as const,
source_evidence_ids: evidence
.map(({ evidence_id: id }) => id)
.sort(compare),
components,
runtime_families: runtimeFamilies(all),
bridge_candidates: bridgeProjection.candidates,
coverage: {
status:
omittedComponents > 0 || bridgeProjection.omitted > 0
? ("truncated" as const)
: inventory.complete
? ("complete-within-inventory" as const)
: ("partial" as const),
inventory_complete: inventory.complete,
omitted_components: omittedComponents,
omitted_bridge_candidates: bridgeProjection.omitted,
},
limitations,
};
return androidApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `adp_${digest(withoutId)}`,
});
};
const classify = (all: readonly Component[]) => ({
manifests: all.filter(
({ path, format }) =>
format === "android-manifest" ||
/(?:^|\/)AndroidManifest\.xml$/u.test(path),
),
resources: all.filter(
({ path, format }) =>
format === "android-resources" || /(?:^|\/)resources\.arsc$/u.test(path),
),
dex: all.filter(
({ path, format }) =>
format === "dex" || /(?:^|\/)[^/]+\.dex$/iu.test(path),
),
jvm_classes: all.filter(
({ path, format }) =>
format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path),
),
native_libraries: all.filter(
({ path, format }) =>
format === "elf" || /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/iu.test(path),
),
javascript: all.filter(({ format }) => format === "javascript-bundle"),
signing: all.filter(({ path }) =>
/^META-INF\/[^/]+\.(?:MF|RSA|DSA|EC|SF)$/iu.test(path),
),
});
const retain = <Groups extends Record<string, readonly Component[]>>(
groups: Groups,
maximum: number,
): { [Key in keyof Groups]: Component[] } => {
let remaining = maximum;
return Object.fromEntries(
Object.entries(groups).map(([key, values]) => {
const selected = [...values]
.sort((left, right) => compare(left.path, right.path))
.slice(0, remaining);
remaining -= selected.length;
return [key, selected];
}),
) as { [Key in keyof Groups]: Component[] };
};
const count = (groups: Record<string, readonly Component[]>): number =>
Object.values(groups).reduce((total, values) => total + values.length, 0);
const runtimeFamilies = (all: readonly Component[]) => {
const paths = all.map(({ path }) => path.toLowerCase());
const families = new Set<
AndroidApplicationProjectionResult["runtime_families"][number]
>();
if (all.some(({ path, format }) => format === "dex" || path.endsWith(".dex")))
families.add("dalvik-art");
if (
all.some(
({ path, format }) => format === "jvm-class" || path.endsWith(".class"),
)
)
families.add("java-kotlin");
if (all.some(({ format }) => format === "elf")) families.add("native");
if (all.some(({ format }) => format === "javascript-bundle"))
families.add("javascript");
if (
paths.some(
(path) => path.includes("reactnative") || path.includes("hermes"),
)
)
families.add("react-native");
if (paths.some((path) => path.includes("libflutter.so")))
families.add("flutter");
if (paths.some((path) => path.includes("libunity.so"))) families.add("unity");
return [...families].sort(compare);
};
const bridgeCandidates = (
managed: readonly Component[],
native: readonly Component[],
maximum: number,
) => {
const candidates = managed.flatMap((source) =>
native.map((target) => ({
managed_path: source.path,
native_path: target.path,
basis: bridgeBasis(target.path),
})),
);
return {
candidates: candidates.slice(0, maximum),
omitted: Math.max(0, candidates.length - maximum),
};
};
const bridgeBasis = (
path: string,
): AndroidApplicationProjectionResult["bridge_candidates"][number]["basis"] => {
const lower = path.toLowerCase();
if (lower.includes("react") || lower.includes("hermes"))
return "react-native-convention";
if (lower.includes("flutter")) return "flutter-convention";
if (lower.includes("unity")) return "unity-convention";
if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(lower)) return "jni-library-convention";
return "managed-and-native-content";
};
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Android application projection is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
+315
View File
@@ -0,0 +1,315 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { evidenceSchema } from "./evidence.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
const boundedPathSchema = z.string().min(1).max(4_096);
const componentSchema = z.strictObject({
path: boundedPathSchema,
artifact_id: z.string().regex(/^art_[a-f0-9]{64}$/u),
sha256: digestSchema,
format: z.string().min(1).max(100),
});
/** Authenticated artifact inventory pages projected as one Apple application. */
export const appleApplicationProjectionInputSchema = z.strictObject({
inventory_evidence: z.array(evidenceSchema).min(1).max(100),
limits: z
.strictObject({
max_components: z.number().int().min(1).max(10_000).default(1_000),
})
.default({ max_components: 1_000 }),
});
/** Deterministic, execution-free Apple application inventory projection. */
export const appleApplicationProjectionResultSchema = z.strictObject({
schema_version: z.literal(1),
projection_id: z.string().regex(/^aap_[a-f0-9]{64}$/u),
root_sha256: digestSchema,
root_format: z.literal("ipa"),
source_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
application_roots: z.array(boundedPathSchema),
components: z.strictObject({
bundle_metadata: z.array(componentSchema),
executables: z.array(componentSchema),
frameworks: z.array(componentSchema),
native_libraries: z.array(componentSchema),
javascript: z.array(componentSchema),
signing: z.array(componentSchema),
}),
runtime_families: z.array(
z.enum([
"native",
"swift-objective-c",
"javascript",
"react-native",
"flutter",
"unity",
]),
),
bridge_candidates: z.array(
z.strictObject({
source_path: boundedPathSchema,
native_path: boundedPathSchema,
basis: z.enum([
"javascript-and-native-content",
"react-native-convention",
"flutter-convention",
"unity-convention",
]),
}),
),
coverage: z.strictObject({
status: z.enum(["complete-within-inventory", "partial", "truncated"]),
inventory_complete: z.boolean(),
omitted_components: z.number().int().min(0),
omitted_bridge_candidates: z.number().int().min(0),
}),
limitations: z.array(z.string().min(1).max(4_096)).max(100),
});
export type AppleApplicationProjectionInput = z.infer<
typeof appleApplicationProjectionInputSchema
>;
export type AppleApplicationProjectionResult = z.infer<
typeof appleApplicationProjectionResultSchema
>;
type Component = z.infer<typeof componentSchema>;
/** Project exact IPA paths and hashes without parsing or executing target code. */
export const projectAppleApplication = (
input: AppleApplicationProjectionInput,
): AppleApplicationProjectionResult => {
const parsed = appleApplicationProjectionInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
);
if (inventory.manifest.root_format !== "ipa")
throw new TypeError("Apple application projection requires IPA Evidence");
const nodes = new Map(
inventory.nodes.map((node) => [node.artifact_id, node]),
);
const all = inventory.occurrences
.filter(
(occurrence) =>
occurrence.artifact_id !== null && occurrence.logical_path !== ".",
)
.map((occurrence) => {
const node = nodes.get(occurrence.artifact_id ?? "");
if (node === undefined)
throw new TypeError(
"Apple application occurrence has no artifact node",
);
return {
path: occurrence.logical_path,
artifact_id: node.artifact_id,
sha256: node.sha256,
format: node.format,
} satisfies Component;
});
const roots = inventory.occurrences
.filter(
({ logical_path: path, entry_kind: kind }) =>
kind === "directory" && /^Payload\/[^/]+\.app$/u.test(path),
)
.map(({ logical_path: path }) => path)
.sort(compare);
const classified = classifyComponents(all, roots);
const retained = retainComponents(classified, parsed.limits.max_components);
const omitted = componentCount(classified) - componentCount(retained);
const runtimeFamilies = identifyRuntimeFamilies(all);
const bridgeProjection = identifyBridgeCandidates(
retained.javascript,
[...retained.frameworks, ...retained.native_libraries],
parsed.limits.max_components,
);
const limitations = projectionLimitations(
inventory.complete,
omitted,
bridgeProjection.omitted,
roots,
);
const withoutId = {
schema_version: 1 as const,
root_sha256: inventory.manifest.root_sha256,
root_format: "ipa" as const,
source_evidence_ids: evidence
.map(({ evidence_id: id }) => id)
.sort(compare),
application_roots: roots,
components: retained,
runtime_families: runtimeFamilies,
bridge_candidates: bridgeProjection.candidates,
coverage: {
status:
omitted > 0 || bridgeProjection.omitted > 0
? ("truncated" as const)
: inventory.complete
? ("complete-within-inventory" as const)
: ("partial" as const),
inventory_complete: inventory.complete,
omitted_components: omitted,
omitted_bridge_candidates: bridgeProjection.omitted,
},
limitations,
};
return appleApplicationProjectionResultSchema.parse({
...withoutId,
projection_id: `aap_${digest(withoutId)}`,
});
};
const classifyComponents = (
all: readonly Component[],
roots: readonly string[],
) => {
const withinApp = (path: string): boolean =>
roots.some((root) => path.startsWith(`${root}/`));
return {
bundle_metadata: all.filter(({ path }) =>
/(?:^|\/)Info\.plist$/u.test(path),
),
executables: all.filter(
({ path, format }) =>
withinApp(path) && ["mach-o", "mach-o-universal"].includes(format),
),
frameworks: all.filter(({ path }) => /\.framework\//u.test(path)),
native_libraries: all.filter(({ path }) => /\.(?:dylib|so)$/iu.test(path)),
javascript: all.filter(({ format }) => format === "javascript-bundle"),
signing: all.filter(({ path }) =>
/(?:^|\/)(?:embedded\.mobileprovision|_CodeSignature\/CodeResources)$/u.test(
path,
),
),
};
};
const retainComponents = <Groups extends Record<string, readonly Component[]>>(
groups: Groups,
maximum: number,
): { [Key in keyof Groups]: Component[] } => {
let remaining = maximum;
return Object.fromEntries(
Object.entries(groups).map(([key, values]) => {
const retained = [...values]
.sort((left, right) => compare(left.path, right.path))
.slice(0, remaining);
remaining -= retained.length;
return [key, retained];
}),
) as { [Key in keyof Groups]: Component[] };
};
const componentCount = (groups: Record<string, readonly Component[]>): number =>
Object.values(groups).reduce((total, values) => total + values.length, 0);
const identifyRuntimeFamilies = (all: readonly Component[]) => {
const paths = all.map(({ path }) => path.toLowerCase());
const families = new Set<
z.infer<
typeof appleApplicationProjectionResultSchema
>["runtime_families"][number]
>();
if (all.some(({ format }) => ["mach-o", "mach-o-universal"].includes(format)))
families.add("native");
if (paths.some((path) => /\.(?:dylib|framework\/[^/]+)$/u.test(path)))
families.add("swift-objective-c");
if (all.some(({ format }) => format === "javascript-bundle"))
families.add("javascript");
if (
paths.some(
(path) =>
path.includes("reactnative") ||
path.includes("react.framework") ||
path.includes("hermes"),
)
)
families.add("react-native");
if (
paths.some(
(path) =>
path.includes("flutter.framework") || path.includes("app.framework"),
)
)
families.add("flutter");
if (paths.some((path) => path.includes("unityframework.framework")))
families.add("unity");
return [...families].sort(compare);
};
const identifyBridgeCandidates = (
scripts: readonly Component[],
native: readonly Component[],
maximum: number,
) => {
const candidates = scripts.flatMap((script) =>
native.map((item) => ({
source_path: script.path,
native_path: item.path,
basis: bridgeBasis(item.path),
})),
);
return {
candidates: candidates.slice(0, maximum),
omitted: Math.max(0, candidates.length - maximum),
};
};
const bridgeBasis = (
path: string,
):
| "javascript-and-native-content"
| "react-native-convention"
| "flutter-convention"
| "unity-convention" => {
const lower = path.toLowerCase();
if (lower.includes("react")) return "react-native-convention";
if (lower.includes("flutter") || lower.includes("app.framework"))
return "flutter-convention";
if (lower.includes("unity")) return "unity-convention";
return "javascript-and-native-content";
};
const projectionLimitations = (
complete: boolean,
omitted: number,
omittedBridges: number,
roots: readonly string[],
): string[] => [
...(!complete
? ["Source inventory pages are incomplete; absence is unknown."]
: []),
...(omitted > 0
? [
`${String(omitted)} component observations were omitted by the projection limit.`,
]
: []),
...(omittedBridges > 0
? [
`${String(omittedBridges)} bridge hypotheses were omitted by the projection limit.`,
]
: []),
...(roots.length === 0
? [
"No Payload/*.app directory was present in the supplied inventory pages.",
]
: []),
"Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.",
"Bridge candidates are path-based hypotheses, not observed runtime calls.",
];
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Apple application projection is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
+243
View File
@@ -0,0 +1,243 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
import { evidenceSchema } from "./evidence.js";
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
const runtimeFamilySchema = z.enum([
"android",
"apple",
"javascript",
"jvm",
"native",
"webassembly",
]);
const observationSchema = z.strictObject({
path: z.string().min(1).max(4_096),
artifact_id: z.string().regex(/^art_[a-f0-9]{64}$/u),
sha256: digestSchema,
format: z.string().min(1).max(100),
});
/** Authenticated artifact inventory pages to classify by runtime family. */
const runtimeIdentificationInputSchema = z.strictObject({
inventory_evidence: z.array(evidenceSchema).min(1).max(100),
limits: z
.strictObject({
max_observations: z.number().int().min(1).max(10_000).default(1_000),
})
.default({ max_observations: 1_000 }),
});
/** Provider-neutral runtime identification with explicit tooling availability. */
export const runtimeIdentificationResultSchema = z.strictObject({
schema_version: z.literal(1),
identification_id: z.string().regex(/^rid_[a-f0-9]{64}$/u),
root_sha256: digestSchema,
root_format: z.string().min(1).max(100),
source_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
runtimes: z.array(
z.strictObject({
family: runtimeFamilySchema,
inspection: z.enum([
"available",
"provider-missing",
"provider-selection-required",
]),
provider_id: z.string().min(1).max(200).nullable(),
reason: z.string().min(1).max(1_000).nullable(),
observations: z.array(observationSchema),
omitted_observations: z.number().int().min(0),
}),
),
coverage: z.strictObject({
status: z.enum(["complete-within-inventory", "partial", "truncated"]),
inventory_complete: z.boolean(),
omitted_observations: z.number().int().min(0),
}),
limitations: z.array(z.string().min(1).max(4_096)).max(100),
});
export type RuntimeIdentificationInput = z.input<
typeof runtimeIdentificationInputSchema
>;
export type RuntimeIdentificationResult = z.infer<
typeof runtimeIdentificationResultSchema
>;
type RuntimeFamily = z.infer<typeof runtimeFamilySchema>;
type Observation = z.infer<typeof observationSchema>;
type RuntimeProvider = {
readonly inspection:
| "available"
| "provider-missing"
| "provider-selection-required";
readonly id: string | null;
readonly reason: string | null;
};
const PROVIDERS: Readonly<Record<RuntimeFamily, RuntimeProvider>> = {
android: {
inspection: "provider-missing",
id: null,
reason: "Install or configure an Android semantic-analysis provider.",
},
apple: { inspection: "available", id: "rea-apple-application", reason: null },
javascript: {
inspection: "available",
id: "rea-javascript-application",
reason: null,
},
jvm: {
inspection: "provider-missing",
id: null,
reason: "Install or configure a JVM bytecode-analysis provider.",
},
native: {
inspection: "provider-selection-required",
id: null,
reason:
"Select an available native deep-analysis provider for this target and host.",
},
webassembly: {
inspection: "provider-missing",
id: null,
reason: "Install or configure a WebAssembly semantic-analysis provider.",
},
};
/** Identify runtime families without executing or semantically decoding target bytes. */
export const identifyRuntimes = (
input: RuntimeIdentificationInput,
): RuntimeIdentificationResult => {
const parsed = runtimeIdentificationInputSchema.parse(input);
const { evidence, inventory } = parseArtifactInventoryEvidence(
parsed.inventory_evidence,
);
const nodes = new Map(
inventory.nodes.map((node) => [node.artifact_id, node]),
);
const grouped = new Map<RuntimeFamily, Observation[]>();
const add = (family: RuntimeFamily, observation: Observation): void => {
const observations = grouped.get(family) ?? [];
observations.push(observation);
grouped.set(family, observations);
};
for (const occurrence of inventory.occurrences) {
if (occurrence.artifact_id === null) continue;
const node = nodes.get(occurrence.artifact_id);
if (node === undefined)
throw new TypeError("Runtime observation has no artifact node");
const observation = {
path: occurrence.logical_path,
artifact_id: node.artifact_id,
sha256: node.sha256,
format: node.format,
};
for (const family of familiesFor(node.format, occurrence.logical_path))
add(family, observation);
}
const rootObservation = inventory.nodes.find(
({ sha256 }) => sha256 === inventory.manifest.root_sha256,
);
if (rootObservation !== undefined)
for (const family of familiesFor(inventory.manifest.root_format, "."))
add(family, {
path: ".",
artifact_id: rootObservation.artifact_id,
sha256: rootObservation.sha256,
format: rootObservation.format,
});
let remaining = parsed.limits.max_observations;
let omitted = 0;
const runtimes = [...grouped.entries()]
.sort(([left], [right]) => compare(left, right))
.map(([family, values]) => {
const unique = deduplicate(values);
const observations = unique.slice(0, remaining);
remaining -= observations.length;
const omittedObservations = unique.length - observations.length;
omitted += omittedObservations;
const provider = PROVIDERS[family];
return {
family,
inspection: provider.inspection,
provider_id: provider.id,
reason: provider.reason,
observations,
omitted_observations: omittedObservations,
};
});
const limitations = [
...(!inventory.complete
? [
"Source inventory pages are incomplete; absent runtimes remain unknown.",
]
: []),
...(omitted > 0
? [`${String(omitted)} runtime observations were omitted by the limit.`]
: []),
"Runtime families are identified from exact artifact formats and paths; semantic claims require the named provider.",
];
const withoutId = {
schema_version: 1 as const,
root_sha256: inventory.manifest.root_sha256,
root_format: inventory.manifest.root_format,
source_evidence_ids: evidence
.map(({ evidence_id: id }) => id)
.sort(compare),
runtimes,
coverage: {
status:
omitted > 0
? ("truncated" as const)
: inventory.complete
? ("complete-within-inventory" as const)
: ("partial" as const),
inventory_complete: inventory.complete,
omitted_observations: omitted,
},
limitations,
};
return runtimeIdentificationResultSchema.parse({
...withoutId,
identification_id: `rid_${digest(withoutId)}`,
});
};
const familiesFor = (format: string, path: string): RuntimeFamily[] => {
const families = new Set<RuntimeFamily>();
if (
format === "apk" ||
format === "dex" ||
/(?:^|\/)AndroidManifest\.xml$/u.test(path)
)
families.add("android");
if (format === "ipa") families.add("apple");
if (format === "javascript-bundle") families.add("javascript");
if (format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path))
families.add("jvm");
if (["elf", "mach-o", "mach-o-universal", "pe"].includes(format))
families.add("native");
if (format === "webassembly" || /(?:^|\/)[^/]+\.wasm$/iu.test(path))
families.add("webassembly");
return [...families];
};
const deduplicate = (values: readonly Observation[]): Observation[] =>
[
...new Map(
values.map((value) => [`${value.path}\0${value.artifact_id}`, value]),
).values(),
].sort((left, right) => compare(left.path, right.path));
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Runtime identification is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
+111
View File
@@ -0,0 +1,111 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { projectAndroidApplicationEvidence } from "../src/application/AndroidApplicationService.js";
import { runProviderAnalysis } from "../src/application/DirectAnalysis.js";
import { androidApplicationProjectionResultSchema } from "../src/domain/androidApplication.js";
import { parseEvidence } from "../src/domain/evidence.js";
describe("Android application projection", () => {
it("projects deterministic APK components and explicit bridge hypotheses", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-android-"));
const path = join(root, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
),
);
await writer.add(
"lib/arm64-v8a/libreactnativejni.so",
new Uint8ArrayReader(
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
),
);
await writer.add("assets/index.js", new TextReader("bridge();"));
await writer.add("META-INF/FIXTURE.RSA", new TextReader("opaque signing"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {
node_limit: 500,
occurrence_limit: 500,
edge_limit: 500,
}),
);
const first = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
limits: { max_components: 100 },
});
const second = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
limits: { max_components: 100 },
});
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (!first.ok || !second.ok) return;
const left = androidApplicationProjectionResultSchema.parse(
first.value.normalized_result,
);
const right = androidApplicationProjectionResultSchema.parse(
second.value.normalized_result,
);
expect(left).toEqual(right);
expect(left).toMatchObject({
root_format: "apk",
coverage: {
status: "complete-within-inventory",
inventory_complete: true,
omitted_components: 0,
},
});
expect(left.components.manifests).toHaveLength(1);
expect(left.components.dex).toHaveLength(1);
expect(left.components.native_libraries).toHaveLength(1);
expect(left.components.javascript).toHaveLength(1);
expect(left.components.signing).toHaveLength(1);
expect(left.runtime_families).toEqual(
expect.arrayContaining([
"dalvik-art",
"javascript",
"native",
"react-native",
]),
);
expect(left.bridge_candidates).toEqual([
expect.objectContaining({ basis: "react-native-convention" }),
]);
expect(JSON.stringify(left)).not.toContain("opaque signing");
});
it("rejects non-APK inventory Evidence", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-android-invalid-"));
const path = join(root, "fixture.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("one.js", new TextReader("one"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
expect(
projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { projectAppleApplicationEvidence } from "../src/application/AppleApplicationService.js";
import { runProviderAnalysis } from "../src/application/DirectAnalysis.js";
import { appleApplicationProjectionResultSchema } from "../src/domain/appleApplication.js";
import { parseEvidence } from "../src/domain/evidence.js";
describe("Apple application projection", () => {
it("projects deterministic IPA components and bridge hypotheses from exact inventory Evidence", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-apple-"));
const path = join(root, "Fixture.ipa");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("Payload/Fixture.app/", undefined, { directory: true });
await writer.add(
"Payload/Fixture.app/Info.plist",
new TextReader("<plist><dict/></plist>"),
);
await writer.add(
"Payload/Fixture.app/Fixture",
new Uint8ArrayReader(
Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]),
),
);
await writer.add(
"Payload/Fixture.app/Frameworks/React.framework/React",
new Uint8ArrayReader(
Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]),
),
);
await writer.add(
"Payload/Fixture.app/main.js",
new TextReader("bridge.call();"),
);
await writer.add(
"Payload/Fixture.app/embedded.mobileprovision",
new TextReader("opaque signing bytes"),
);
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {
node_limit: 500,
occurrence_limit: 500,
edge_limit: 500,
}),
);
const first = projectAppleApplicationEvidence({
inventory_evidence: [inventory],
limits: { max_components: 100 },
});
const second = projectAppleApplicationEvidence({
inventory_evidence: [inventory],
limits: { max_components: 100 },
});
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (!first.ok || !second.ok) return;
const left = appleApplicationProjectionResultSchema.parse(
first.value.normalized_result,
);
const right = appleApplicationProjectionResultSchema.parse(
second.value.normalized_result,
);
expect(left).toEqual(right);
expect(left).toMatchObject({
root_format: "ipa",
application_roots: ["Payload/Fixture.app"],
coverage: {
status: "complete-within-inventory",
inventory_complete: true,
omitted_components: 0,
},
});
expect(left.components.bundle_metadata).toHaveLength(1);
expect(left.components.executables.length).toBeGreaterThanOrEqual(2);
expect(left.components.frameworks).toHaveLength(1);
expect(left.components.javascript).toHaveLength(1);
expect(left.components.signing).toHaveLength(1);
expect(left.runtime_families).toEqual(
expect.arrayContaining(["javascript", "native", "react-native"]),
);
expect(left.bridge_candidates).toEqual([
expect.objectContaining({ basis: "react-native-convention" }),
]);
expect(JSON.stringify(left)).not.toContain("opaque signing bytes");
});
it("rejects non-IPA Evidence and reports projection truncation", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-apple-invalid-"));
const path = join(root, "fixture.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("one.js", new TextReader("one"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
expect(
projectAppleApplicationEvidence({ inventory_evidence: [inventory] }),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
});
+125
View File
@@ -0,0 +1,125 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { runProviderAnalysis } from "../src/application/DirectAnalysis.js";
import { parseEvidence } from "../src/domain/evidence.js";
import {
identifyRuntimes,
runtimeIdentificationResultSchema,
} from "../src/domain/runtimeIdentification.js";
describe("runtime identification", () => {
it("identifies APK runtime families and exposes missing semantic providers", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-runtime-"));
const path = join(root, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0, 8, 0, 0, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([...Buffer.from("dex\n035\0"), 0, 0, 0, 0]),
),
);
await writer.add(
"assets/Fixture.class",
new Uint8ArrayReader(
Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 61]),
),
);
await writer.add(
"assets/module.wasm",
new Uint8ArrayReader(Uint8Array.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0])),
);
await writer.add("assets/main.js", new TextReader("bridge.call();"));
await writer.add(
"lib/arm64-v8a/libfixture.so",
new Uint8ArrayReader(
Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]),
),
);
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {
node_limit: 100,
occurrence_limit: 100,
edge_limit: 100,
}),
);
const first = identifyRuntimes({ inventory_evidence: [inventory] });
const second = identifyRuntimes({ inventory_evidence: [inventory] });
expect(first).toEqual(second);
expect(runtimeIdentificationResultSchema.parse(first)).toMatchObject({
root_format: "apk",
coverage: {
status: "complete-within-inventory",
inventory_complete: true,
omitted_observations: 0,
},
});
expect(first.runtimes.map(({ family }) => family)).toEqual([
"android",
"javascript",
"jvm",
"native",
"webassembly",
]);
expect(first.runtimes).toEqual(
expect.arrayContaining([
expect.objectContaining({
family: "jvm",
inspection: "provider-missing",
provider_id: null,
}),
expect.objectContaining({
family: "webassembly",
inspection: "provider-missing",
provider_id: null,
}),
expect.objectContaining({
family: "javascript",
inspection: "available",
provider_id: "rea-javascript-application",
}),
expect.objectContaining({
family: "native",
inspection: "provider-selection-required",
provider_id: null,
}),
]),
);
});
it("reports bounded runtime-observation truncation", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-runtime-limit-"));
const path = join(root, "Fixture.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("one.js", new TextReader("one"));
await writer.add("two.js", new TextReader("two"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const result = identifyRuntimes({
inventory_evidence: [inventory],
limits: { max_observations: 1 },
});
expect(result.coverage).toMatchObject({
status: "truncated",
omitted_observations: expect.any(Number),
});
expect(result.coverage.omitted_observations).toBeGreaterThan(0);
});
});