Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/domain/mobileApplicationInvestigation.ts
Original file line number Diff line number Diff line change
@@ -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];
}
129 changes: 129 additions & 0 deletions tests/mobileApplicationInvestigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";

import {
classifyMobileResource,
detectMobileFormat,
detectPlatform,
getAllArchitectures,
hasInternetPermission,
mobileArtifactManifestSchema,
nativeLibsByArchitecture,
type MobileArtifactManifest,
type AndroidManifest,
type IosManifest,

Check warning on line 13 in tests/mobileApplicationInvestigation.test.ts

View workflow job for this annotation

GitHub Actions / Static checks

eslint(no-unused-vars)

Type 'IosManifest' is imported but never used.
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