From fdeeb27f23041c19b5857fe7873106f0cd9c6244 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:44:16 +0000 Subject: [PATCH] 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 --- src/domain/mobileApplicationInvestigation.ts | 179 +++++++++++++++++++ tests/mobileApplicationInvestigation.test.ts | 129 +++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 src/domain/mobileApplicationInvestigation.ts create mode 100644 tests/mobileApplicationInvestigation.test.ts diff --git a/src/domain/mobileApplicationInvestigation.ts b/src/domain/mobileApplicationInvestigation.ts new file mode 100644 index 00000000..2ec08134 --- /dev/null +++ b/src/domain/mobileApplicationInvestigation.ts @@ -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; + +/** Mobile platform. */ +export const mobilePlatformSchema = z.enum([ + "android", + "ios", + "cross_platform", +]); +export type MobilePlatform = z.infer; + +/** 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; + +/** 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; + +/** 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; + +/** 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; + +/** 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; + +/** 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(); + 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]; +} diff --git a/tests/mobileApplicationInvestigation.test.ts b/tests/mobileApplicationInvestigation.test.ts new file mode 100644 index 00000000..a51410c6 --- /dev/null +++ b/tests/mobileApplicationInvestigation.test.ts @@ -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"); + }); +});