From d36b38537821dd2d74d99a9e49eccd569cfaf341 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:04:57 +0000 Subject: [PATCH] feat(artifacts): project mobile application inventories --- src/application/AndroidApplicationService.ts | 27 ++ src/application/AppleApplicationService.ts | 27 ++ .../InventoryProjectionEvidence.ts | 91 +++++ src/application/InvestigationProviders.ts | 14 + src/domain/androidApplication.ts | 283 ++++++++++++++++ src/domain/appleApplication.ts | 315 ++++++++++++++++++ src/domain/runtimeIdentification.ts | 243 ++++++++++++++ tests/androidApplication.test.ts | 111 ++++++ tests/appleApplication.test.ts | 114 +++++++ tests/runtimeIdentification.test.ts | 125 +++++++ 10 files changed, 1350 insertions(+) create mode 100644 src/application/AndroidApplicationService.ts create mode 100644 src/application/AppleApplicationService.ts create mode 100644 src/application/InventoryProjectionEvidence.ts create mode 100644 src/domain/androidApplication.ts create mode 100644 src/domain/appleApplication.ts create mode 100644 src/domain/runtimeIdentification.ts create mode 100644 tests/androidApplication.test.ts create mode 100644 tests/appleApplication.test.ts create mode 100644 tests/runtimeIdentification.test.ts diff --git a/src/application/AndroidApplicationService.ts b/src/application/AndroidApplicationService.ts new file mode 100644 index 0000000000..2c98b433a7 --- /dev/null +++ b/src/application/AndroidApplicationService.ts @@ -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 => { + 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", + }); +}; diff --git a/src/application/AppleApplicationService.ts b/src/application/AppleApplicationService.ts new file mode 100644 index 0000000000..29ae9c19be --- /dev/null +++ b/src/application/AppleApplicationService.ts @@ -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 => { + 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", + }); +}; diff --git a/src/application/InventoryProjectionEvidence.ts b/src/application/InventoryProjectionEvidence.ts new file mode 100644 index 0000000000..9be44d123c --- /dev/null +++ b/src/application/InventoryProjectionEvidence.ts @@ -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; + 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, +): Result => { + 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 }), + ); + } +}; diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 4bf2f2b1c5..638eb51705 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -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", diff --git a/src/domain/androidApplication.ts b/src/domain/androidApplication.ts new file mode 100644 index 0000000000..9255f32175 --- /dev/null +++ b/src/domain/androidApplication.ts @@ -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; + +/** 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: 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): 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"); +}; diff --git a/src/domain/appleApplication.ts b/src/domain/appleApplication.ts new file mode 100644 index 0000000000..82f5c4b37d --- /dev/null +++ b/src/domain/appleApplication.ts @@ -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; + +/** 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: 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): 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"); +}; diff --git a/src/domain/runtimeIdentification.ts b/src/domain/runtimeIdentification.ts new file mode 100644 index 0000000000..7418506e42 --- /dev/null +++ b/src/domain/runtimeIdentification.ts @@ -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; +type Observation = z.infer; +type RuntimeProvider = { + readonly inspection: + | "available" + | "provider-missing" + | "provider-selection-required"; + readonly id: string | null; + readonly reason: string | null; +}; + +const PROVIDERS: Readonly> = { + 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(); + 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(); + 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"); +}; diff --git a/tests/androidApplication.test.ts b/tests/androidApplication.test.ts new file mode 100644 index 0000000000..35323a26c9 --- /dev/null +++ b/tests/androidApplication.test.ts @@ -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" }, + }); + }); +}); diff --git a/tests/appleApplication.test.ts b/tests/appleApplication.test.ts new file mode 100644 index 0000000000..779c300e43 --- /dev/null +++ b/tests/appleApplication.test.ts @@ -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(""), + ); + 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" }, + }); + }); +}); diff --git a/tests/runtimeIdentification.test.ts b/tests/runtimeIdentification.test.ts new file mode 100644 index 0000000000..07bc65857f --- /dev/null +++ b/tests/runtimeIdentification.test.ts @@ -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); + }); +});