feat(package): add MSIX/MSI/AppX package, resource, and signature analysis
Add static analysis for MSIX, MSI, AppX packages, their resources, and digital signatures (#363). - packageAnalysis.ts: domain schemas for Windows package formats (MSIX, MSI, AppX), resources (manifest, icon, configuration, certificate, executable, library, data), and digital signatures. - detectPackageFormat: classifies from file extension - classifyResource: identifies resource type from path - Digital signature validation with timestamp detection - Package and resource contents are inventoried without execution Closes #363
This commit is contained in:
1 parent
645668b70d
commit
6c03f83b1b
2 files changed
+251
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user