mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(artifacts): project mobile application inventories
This commit is contained in:
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 }),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user