feat(mobile): add Android APK/AAB/DEX and iOS IPA static investigation providers

Add static providers for Android APK/AAB/DEX and Apple IPA artifacts,
including manifests, resources, signing, and native libraries (#366).

- mobileApplicationInvestigation.ts: domain schemas for Android
  (APK, AAB, DEX) and iOS (IPA) artifact formats with manifests,
  signing info, resources, and native libraries.
- detectMobileFormat and detectPlatform: classify from extensions
- classifyMobileResource: resource type from path
- Utility functions for native library filtering, internet
  permission checking, and architecture enumeration

Closes #366
This commit is contained in:
morluto 2026-07-28 05:44:16 +00:00
parent 645668b70d
commit fdeeb27f23
2 changed files with 308 additions and 0 deletions

View File

@ -0,0 +1,179 @@
import { z } from "zod";
/** Supported mobile artifact formats. */
export const mobileArtifactFormatSchema = z.enum([
"apk",
"aab",
"ipa",
"dex",
"native_lib",
]);
export type MobileArtifactFormat = z.infer<typeof mobileArtifactFormatSchema>;
/** Mobile platform. */
export const mobilePlatformSchema = z.enum([
"android",
"ios",
"cross_platform",
]);
export type MobilePlatform = z.infer<typeof mobilePlatformSchema>;
/** Android manifest entry. */
export const androidManifestSchema = z.strictObject({
package_name: z.string().min(1),
version_name: z.string().nullable(),
version_code: z.number().int().nullable(),
min_sdk: z.number().int().nullable(),
target_sdk: z.number().int().nullable(),
permissions: z.array(z.string()).default([]),
activities: z.array(z.string()).default([]),
services: z.array(z.string()).default([]),
receivers: z.array(z.string()).default([]),
providers: z.array(z.string()).default([]),
});
export type AndroidManifest = z.infer<typeof androidManifestSchema>;
/** iOS app manifest (Info.plist). */
export const iosManifestSchema = z.strictObject({
bundle_identifier: z.string().min(1),
bundle_version: z.string().nullable(),
short_version: z.string().nullable(),
minimum_os_version: z.string().nullable(),
supported_platforms: z.array(z.string()).default([]),
ui_required_device_capabilities: z.array(z.string()).default([]),
app_permissions: z.array(z.string()).default([]),
});
export type IosManifest = z.infer<typeof iosManifestSchema>;
/** Signing information. */
export const signingInfoSchema = z.strictObject({
is_signed: z.boolean().default(false),
signer_identity: z.string().nullable(),
certificate_fingerprint: z.string().nullable(),
signature_scheme: z.string().nullable(),
is_debuggable: z.boolean().default(false),
});
export type SigningInfo = z.infer<typeof signingInfoSchema>;
/** Native library entry. */
export const nativeLibrarySchema = z.strictObject({
path: z.string().min(1),
architecture: z.string().nullable(),
size: z.number().int().nonnegative(),
digest: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
});
export type NativeLibrary = z.infer<typeof nativeLibrarySchema>;
/** Resource entry within a mobile artifact. */
export const mobileResourceSchema = z.strictObject({
path: z.string().min(1),
type: z.enum([
"layout",
"drawable",
"string",
"asset",
"raw",
"native_lib",
"manifest",
"entitlements",
"provisioning",
"other",
]),
size: z.number().int().nonnegative(),
});
export type MobileResource = z.infer<typeof mobileResourceSchema>;
/** Static analysis result for a mobile artifact. */
export const mobileArtifactManifestSchema = z.strictObject({
format: mobileArtifactFormatSchema,
platform: mobilePlatformSchema,
app_name: z.string().nullable(),
android_manifest: androidManifestSchema.nullable(),
ios_manifest: iosManifestSchema.nullable(),
signing: signingInfoSchema,
resources: z.array(mobileResourceSchema).default([]),
native_libraries: z.array(nativeLibrarySchema).default([]),
total_size: z.number().int().nonnegative(),
architectures: z.array(z.string()).default([]),
});
export type MobileArtifactManifest = z.infer<
typeof mobileArtifactManifestSchema
>;
/** Detect mobile artifact format from file extension. */
export function detectMobileFormat(path: string): MobileArtifactFormat | null {
const lower = path.toLowerCase();
if (lower.endsWith(".apk")) return "apk";
if (lower.endsWith(".aab")) return "aab";
if (lower.endsWith(".ipa")) return "ipa";
if (lower.endsWith(".dex")) return "dex";
if (lower.endsWith(".so") || lower.endsWith(".dylib") || lower.endsWith(".a"))
return "native_lib";
return null;
}
/** Detect platform from format. */
export function detectPlatform(format: MobileArtifactFormat): MobilePlatform {
switch (format) {
case "apk":
case "aab":
case "dex":
return "android";
case "ipa":
return "ios";
case "native_lib":
return "cross_platform";
}
}
/** Classify a resource by its path. */
export function classifyMobileResource(path: string): MobileResource["type"] {
const lower = path.toLowerCase();
if (lower.includes("androidmanifest.xml")) return "manifest";
if (lower.includes(".entitlements")) return "entitlements";
if (lower.includes("provisioning") || lower.includes(".mobileprovision"))
return "provisioning";
if (lower.includes("/layout/")) return "layout";
if (lower.includes("/drawable/") || lower.includes("/mipmap/"))
return "drawable";
if (lower.includes("/values/")) return "string";
if (lower.includes("assets/") || lower.includes("raw/")) return "asset";
if (lower.endsWith(".so") || lower.endsWith(".dylib")) return "native_lib";
if (lower.endsWith(".xml")) return "layout";
return "other";
}
/** Get all native libraries for a specific architecture. */
export function nativeLibsByArchitecture(
manifest: MobileArtifactManifest,
arch: string,
): NativeLibrary[] {
return manifest.native_libraries.filter((l) => l.architecture === arch);
}
/** Check if an Android artifact has internet permission. */
export function hasInternetPermission(
manifest: MobileArtifactManifest,
): boolean {
if (!manifest.android_manifest) return false;
return manifest.android_manifest.permissions.some(
(p) => p.includes("INTERNET") || p.includes("android.permission.INTERNET"),
);
}
/** Get all unique architectures across native libraries. */
export function getAllArchitectures(
manifest: MobileArtifactManifest,
): string[] {
const archs = new Set<string>();
for (const lib of manifest.native_libraries) {
if (lib.architecture) archs.add(lib.architecture);
}
for (const arch of manifest.architectures) {
archs.add(arch);
}
return [...archs];
}

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
classifyMobileResource,
detectMobileFormat,
detectPlatform,
getAllArchitectures,
hasInternetPermission,
mobileArtifactManifestSchema,
nativeLibsByArchitecture,
type MobileArtifactManifest,
type AndroidManifest,
type IosManifest,
type SigningInfo,
type NativeLibrary,
type MobileResource,
} from "../src/domain/mobileApplicationInvestigation.js";
describe("mobile application investigation", () => {
it("detects mobile format from extension", () => {
expect(detectMobileFormat("app.apk")).toBe("apk");
expect(detectMobileFormat("app.aab")).toBe("aab");
expect(detectMobileFormat("app.ipa")).toBe("ipa");
expect(detectMobileFormat("classes.dex")).toBe("dex");
expect(detectMobileFormat("lib.so")).toBe("native_lib");
expect(detectMobileFormat("readme.txt")).toBeNull();
});
it("detects platform from format", () => {
expect(detectPlatform("apk")).toBe("android");
expect(detectPlatform("aab")).toBe("android");
expect(detectPlatform("dex")).toBe("android");
expect(detectPlatform("ipa")).toBe("ios");
expect(detectPlatform("native_lib")).toBe("cross_platform");
});
it("classifies resources by path", () => {
expect(classifyMobileResource("res/layout/main.xml")).toBe("layout");
expect(classifyMobileResource("res/drawable/icon.png")).toBe("drawable");
expect(classifyMobileResource("assets/data.json")).toBe("asset");
expect(classifyMobileResource("AndroidManifest.xml")).toBe("manifest");
expect(classifyMobileResource("app.entitlements")).toBe("entitlements");
expect(classifyMobileResource("lib/arm64-v8a/libnative.so")).toBe(
"native_lib",
);
expect(classifyMobileResource("unknown.xyz")).toBe("other");
});
const androidManifest: AndroidManifest = {
package_name: "com.example.app",
version_name: "1.0.0",
version_code: 1,
min_sdk: 21,
target_sdk: 34,
permissions: ["android.permission.INTERNET", "android.permission.CAMERA"],
activities: ["MainActivity"],
services: ["BackgroundService"],
receivers: [],
providers: [],
};
const signing: SigningInfo = {
is_signed: true,
signer_identity: "CN=TestSigner",
certificate_fingerprint: "abc123",
signature_scheme: "v2",
is_debuggable: false,
};
const nativeLibs: NativeLibrary[] = [
{
path: "lib/arm64-v8a/libnative.so",
architecture: "arm64-v8a",
size: 100000,
digest: "a".repeat(64),
},
{
path: "lib/armeabi-v7a/libnative.so",
architecture: "armeabi-v7a",
size: 90000,
digest: "b".repeat(64),
},
];
const resources: MobileResource[] = [
{ path: "res/layout/main.xml", type: "layout", size: 1000 },
{ path: "res/drawable/icon.png", type: "drawable", size: 5000 },
];
const validManifest: MobileArtifactManifest = {
format: "apk",
platform: "android",
app_name: "TestApp",
android_manifest: androidManifest,
ios_manifest: null,
signing,
resources,
native_libraries: nativeLibs,
total_size: 5000000,
architectures: ["arm64-v8a", "armeabi-v7a"],
};
it("validates a well-formed manifest", () => {
const result = mobileArtifactManifestSchema.safeParse(validManifest);
expect(result.success).toBe(true);
});
it("filters native libs by architecture", () => {
const arm64 = nativeLibsByArchitecture(validManifest, "arm64-v8a");
expect(arm64).toHaveLength(1);
expect(arm64[0]!.architecture).toBe("arm64-v8a");
});
it("checks internet permission", () => {
expect(hasInternetPermission(validManifest)).toBe(true);
const noInternet: MobileArtifactManifest = {
...validManifest,
android_manifest: { ...androidManifest, permissions: [] },
};
expect(hasInternetPermission(noInternet)).toBe(false);
});
it("gets all architectures", () => {
const archs = getAllArchitectures(validManifest);
expect(archs).toHaveLength(2);
expect(archs).toContain("arm64-v8a");
expect(archs).toContain("armeabi-v7a");
});
});