diff --git a/src/domain/packageAnalysis.ts b/src/domain/packageAnalysis.ts new file mode 100644 index 00000000..fe917cf1 --- /dev/null +++ b/src/domain/packageAnalysis.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; + +/** Supported Windows package formats. */ +export const packageFormatSchema = z.enum(["msix", "msi", "appx"]); +export type PackageFormat = z.infer; + +/** A resource entry within a package. */ +export const packageResourceSchema = z.strictObject({ + /** Resource path within the package. */ + path: z.string().min(1), + /** Resource type. */ + type: z.enum([ + "manifest", + "icon", + "configuration", + "certificate", + "executable", + "library", + "data", + "other", + ]), + /** Size in bytes. */ + size: z.number().int().nonnegative(), + /** SHA-256 digest. */ + digest: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), +}); +export type PackageResource = z.infer; + +/** Digital signature information. */ +export const digitalSignatureSchema = z.strictObject({ + /** Whether the package is signed. */ + is_signed: z.boolean(), + /** Signer subject name. */ + signer_subject: z.string().nullable(), + /** Certificate thumbprint. */ + certificate_thumbprint: z.string().nullable(), + /** Whether the signature is valid. */ + is_valid: z.boolean().default(false), + /** Whether the signature is timestamped. */ + is_timestamped: z.boolean().default(false), + /** Timestamp signer if present. */ + timestamp_signer: z.string().nullable(), +}); +export type DigitalSignature = z.infer; + +/** A Windows package manifest for static analysis. */ +export const packageManifestSchema = z.strictObject({ + /** Package format. */ + format: packageFormatSchema, + /** Package identity name. */ + name: z.string().min(1), + /** Package publisher. */ + publisher: z.string().nullable(), + /** Package version. */ + version: z.string().nullable(), + /** Architecture. */ + architecture: z.enum(["x86", "x64", "arm", "arm64", "neutral", "unknown"]), + /** Package resources. */ + resources: z.array(packageResourceSchema).default([]), + /** Digital signature. */ + signature: digitalSignatureSchema, + /** Whether the package is a framework. */ + is_framework: z.boolean().default(false), + /** Package dependencies. */ + dependencies: z.array(z.string()).default([]), + /** Capabilities declared in the manifest. */ + capabilities: z.array(z.string()).default([]), +}); +export type PackageManifest = z.infer; + +/** Detect package format from file extension. */ +export function detectPackageFormat(path: string): PackageFormat | null { + const lower = path.toLowerCase(); + if (lower.endsWith(".msix")) return "msix"; + if (lower.endsWith(".msi")) return "msi"; + if (lower.endsWith(".appx")) return "appx"; + return null; +} + +/** Check if a resource path looks like a manifest. */ +export function isManifestResource(path: string): boolean { + const lower = path.toLowerCase(); + return ( + lower.includes("appxmanifest.xml") || + lower.includes("appxmanifest") || + lower.includes("manifest.xml") || + lower.endsWith("_manifest.xml") + ); +} + +/** Check if a resource path looks like a configuration file. */ +export function isConfigurationResource(path: string): boolean { + const lower = path.toLowerCase(); + return ( + lower.endsWith(".xml") || + lower.endsWith(".json") || + lower.endsWith(".ini") || + lower.endsWith(".config") + ); +} + +/** Classify a resource by its path. */ +export function classifyResource(path: string): PackageResource["type"] { + if (isManifestResource(path)) return "manifest"; + const lower = path.toLowerCase(); + if ( + lower.endsWith(".ico") || + lower.endsWith(".png") || + lower.endsWith(".jpg") || + lower.endsWith(".jpeg") + ) + return "icon"; + if (isConfigurationResource(path)) return "configuration"; + if ( + lower.endsWith(".cer") || + lower.endsWith(".pfx") || + lower.endsWith(".p12") + ) + return "certificate"; + if (lower.endsWith(".exe") || lower.endsWith(".dll")) + return lower.endsWith(".exe") ? "executable" : "library"; + return "other"; +} + +/** Get all resources of a specific type. */ +export function resourcesByType( + manifest: PackageManifest, + type: PackageResource["type"], +): PackageResource[] { + return manifest.resources.filter((r) => r.type === type); +} + +/** Get the total size of all resources. */ +export function totalResourceSize(manifest: PackageManifest): number { + return manifest.resources.reduce((sum, r) => sum + r.size, 0); +} + +/** Check if a package has a valid signature. */ +export function hasValidSignature(manifest: PackageManifest): boolean { + return manifest.signature.is_signed && manifest.signature.is_valid; +} diff --git a/tests/packageAnalysis.test.ts b/tests/packageAnalysis.test.ts new file mode 100644 index 00000000..d84e07ca --- /dev/null +++ b/tests/packageAnalysis.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyResource, + detectPackageFormat, + hasValidSignature, + isConfigurationResource, + isManifestResource, + packageManifestSchema, + resourcesByType, + totalResourceSize, + type PackageManifest, + type DigitalSignature, +} from "../src/domain/packageAnalysis.js"; + +describe("package analysis", () => { + it("detects package format from extension", () => { + expect(detectPackageFormat("app.msix")).toBe("msix"); + expect(detectPackageFormat("installer.msi")).toBe("msi"); + expect(detectPackageFormat("app.appx")).toBe("appx"); + expect(detectPackageFormat("app.zip")).toBeNull(); + }); + + it("identifies manifest resources", () => { + expect(isManifestResource("AppXManifest.xml")).toBe(true); + expect(isManifestResource("resources.pri")).toBe(false); + }); + + it("identifies configuration resources", () => { + expect(isConfigurationResource("settings.xml")).toBe(true); + expect(isConfigurationResource("config.json")).toBe(true); + expect(isConfigurationResource("app.exe")).toBe(false); + }); + + it("classifies resources by path", () => { + expect(classifyResource("AppXManifest.xml")).toBe("manifest"); + expect(classifyResource("icon.png")).toBe("icon"); + expect(classifyResource("settings.xml")).toBe("configuration"); + expect(classifyResource("cert.cer")).toBe("certificate"); + expect(classifyResource("app.exe")).toBe("executable"); + expect(classifyResource("lib.dll")).toBe("library"); + expect(classifyResource("data.bin")).toBe("other"); + }); + + const validManifest: PackageManifest = { + format: "msix", + name: "TestApp", + publisher: "CN=TestPublisher", + version: "1.0.0.0", + architecture: "x64", + resources: [ + { + path: "AppXManifest.xml", + type: "manifest", + size: 1000, + digest: "a".repeat(64), + }, + { path: "icon.png", type: "icon", size: 5000, digest: "b".repeat(64) }, + { + path: "app.exe", + type: "executable", + size: 50000, + digest: "c".repeat(64), + }, + ], + signature: { + is_signed: true, + signer_subject: "CN=TestPublisher", + certificate_thumbprint: "abc123", + is_valid: true, + is_timestamped: true, + timestamp_signer: "CN=Timestamp", + }, + is_framework: false, + dependencies: ["Microsoft.VCLibs.14.00"], + capabilities: ["internetClient", "localNetwork"], + }; + + it("validates a well-formed manifest", () => { + const result = packageManifestSchema.safeParse(validManifest); + expect(result.success).toBe(true); + }); + + it("filters resources by type", () => { + const icons = resourcesByType(validManifest, "icon"); + expect(icons).toHaveLength(1); + expect(icons[0]!.path).toBe("icon.png"); + }); + + it("computes total resource size", () => { + expect(totalResourceSize(validManifest)).toBe(56000); + }); + + it("checks valid signature", () => { + expect(hasValidSignature(validManifest)).toBe(true); + const unsigned: PackageManifest = { + ...validManifest, + signature: { ...validManifest.signature, is_valid: false }, + }; + expect(hasValidSignature(unsigned)).toBe(false); + }); + + it("uses DigitalSignature type", () => { + const sig: DigitalSignature = validManifest.signature; + expect(sig.is_signed).toBe(true); + }); +});