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
144 changes: 144 additions & 0 deletions src/domain/packageAnalysis.ts
Original file line number Diff line number Diff line change
@@ -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<typeof packageFormatSchema>;

/** 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<typeof packageResourceSchema>;

/** 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<typeof digitalSignatureSchema>;

/** 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<typeof packageManifestSchema>;

/** 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;
}
107 changes: 107 additions & 0 deletions tests/packageAnalysis.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading