rea/src/domain/androidApplication.ts

237 lines
7.7 KiB
TypeScript

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);
const componentSchema = z.strictObject({
path: pathSchema,
artifact_id: z.string().regex(/^art_[a-f0-9]{64}$/u),
sha256: digestSchema,
format: z.string().min(1),
});
/** Authenticated APK inventory pages projected as one Android application. */
export const androidApplicationProjectionInputSchema = z.strictObject({
inventory_evidence: z.array(evidenceSchema).min(1),
});
/** Deterministic, execution-free Android application inventory projection. */
export const androidApplicationProjectionResultSchema = z.strictObject({
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),
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"]),
inventory_complete: z.boolean(),
}),
limitations: z.array(z.string().min(1)),
});
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 bridgeProjection = bridgeCandidates(
[...classified.dex, ...classified.jvm_classes],
classified.native_libraries,
);
const components = classified;
const limitations = [
...(!inventory.complete
? ["Source inventory pages are incomplete; absence is unknown."]
: []),
"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 = {
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: inventory.complete
? ("complete-within-inventory" as const)
: ("partial" as const),
inventory_complete: inventory.complete,
},
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 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[],
) => {
const candidates = managed.flatMap((source) =>
native.map((target) => ({
managed_path: source.path,
native_path: target.path,
basis: bridgeBasis(target.path),
})),
);
return {
candidates,
};
};
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");
};