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:
parent
645668b70d
commit
fdeeb27f23
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue