Files
reverse-engineering-anything/src/dotnet/ManagedStaticProvider.ts
T

314 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import {
createAnalysisExecution,
type AnalysisClient,
type AnalysisOperation,
type AnalysisProvider,
type CapabilityDescriptor,
type ExecutionOptions,
type ProviderIdentity,
} from "../application/AnalysisProvider.js";
import { MANAGED_STATIC_PROVIDER } from "../application/InvestigationProviders.js";
import {
MANAGED_TOOL_CONTRACTS,
managedArtifactInputSchema,
managedMemberInputSchema,
managedNativeBoundaryInputSchema,
type ManagedToolName,
} from "../contracts/managedToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
EvidenceIntegrityError,
ProviderAdapterError,
} from "../domain/errors.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type {
ManagedArtifactInspection,
ManagedMemberInspection,
ManagedNativeBoundaryInspection,
} from "../domain/managedArtifact.js";
import { err, ok } from "../domain/result.js";
import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js";
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js";
import { MANAGED_INSPECTION_DEFAULTS } from "./ManagedInspectionDefaults.js";
const IDENTITY: ProviderIdentity = Object.freeze(MANAGED_STATIC_PROVIDER);
/** Execution-free managed PE/CLI auxiliary provider. */
export class ManagedStaticProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze(
MANAGED_TOOL_CONTRACTS.map((contract) =>
Object.freeze({
provider: IDENTITY,
operation: contract.name,
available: true as const,
reason: null,
effects: Object.freeze({
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
}),
limitations: limitationsFor(contract.name),
}),
),
);
identity(): ProviderIdentity {
return IDENTITY;
}
capabilities(): readonly CapabilityDescriptor[] {
return this.#capabilities;
}
createClient(target: BinaryTarget): AnalysisClient {
return new ManagedStaticClient(target);
}
}
class ManagedStaticClient implements AnalysisClient {
#snapshotBytes: Buffer | undefined;
constructor(private readonly target: BinaryTarget) {}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
if (operation === "health")
return ok(createAnalysisExecution(null, IDENTITY));
if (!isManagedOperation(operation))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Operation is not implemented by the managed static provider.",
),
);
if (this.target.format !== "pe")
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
`Managed static triage requires a PE target; observed ${this.target.format}.`,
),
);
try {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const maxFileBytes = MANAGED_INSPECTION_DEFAULTS.maxFileBytes;
const metadata = await stat(this.target.path);
if (metadata.size > maxFileBytes)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
`Artifact size ${String(metadata.size)} exceeds max_file_bytes ${String(maxFileBytes)}.`,
),
);
const snapshot = this.#snapshotBytes;
const observed =
snapshot === undefined
? await readManagedSnapshot(this.target.path, options?.signal)
: await hashManagedSource(this.target.path, options?.signal);
if (observed.byteLength > maxFileBytes)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
`Artifact grew beyond max_file_bytes ${String(maxFileBytes)} while it was read.`,
),
);
if (observed.sha256 !== this.target.sha256)
return err(
new EvidenceIntegrityError(
`Managed artifact digest changed after open: expected ${this.target.sha256}, observed ${observed.sha256} at ${this.target.path}`,
),
);
const bytes = snapshot ?? observed.bytes;
if (bytes === undefined)
throw new TypeError("Managed snapshot bytes are unavailable");
this.#snapshotBytes = bytes;
const result = inspectManagedOperation(
operation,
parameters,
bytes,
this.target,
);
return ok(
createAnalysisExecution(result, IDENTITY, {
rawResult: null,
limitations: result.limitations,
subject: this.target,
locations: managedLocations(result),
}),
);
} catch (cause: unknown) {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
return err(new ProviderAdapterError(IDENTITY.id, operation, { cause }));
}
}
close(): Promise<void> {
this.#snapshotBytes = undefined;
return Promise.resolve();
}
}
interface ManagedSourceObservation {
readonly byteLength: number;
readonly sha256: string;
readonly bytes?: Buffer;
}
const readManagedSnapshot = async (
path: string,
signal?: AbortSignal,
): Promise<ManagedSourceObservation> => {
const bytes = await readFile(
path,
signal === undefined ? undefined : { signal },
);
return {
byteLength: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes,
};
};
const hashManagedSource = async (
path: string,
signal?: AbortSignal,
): Promise<ManagedSourceObservation> => {
const digest = createHash("sha256");
let byteLength = 0;
const stream = createReadStream(
path,
signal === undefined ? undefined : { signal },
);
for await (const chunk of stream) {
if (!Buffer.isBuffer(chunk))
throw new TypeError("Managed source stream returned non-buffer data");
byteLength += chunk.length;
digest.update(chunk);
}
return { byteLength, sha256: digest.digest("hex") };
};
const isManagedOperation = (
operation: AnalysisOperation,
): operation is ManagedToolName =>
MANAGED_TOOL_CONTRACTS.some(({ name }) => name === operation);
const limitationsFor = (operation: ManagedToolName): readonly string[] =>
Object.freeze(
operation === "inspect_managed_artifact"
? [
"This capability inventories PE/CLI identity only; inspect_managed_members admits bounded metadata members, signatures, and CIL anchors.",
"It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.",
]
: operation === "inspect_managed_native_boundaries"
? [
"This capability inventories managed declarations for native boundaries only; native exports, thunks, and addresses require separate native-provider evidence.",
"It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.",
]
: [
"This capability decodes bounded PE/CLI metadata members, signatures, and file-backed method bodies; decompiled C# reconstruction and cross-build member comparison are separate operations.",
"It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code.",
],
);
const inspectManagedOperation = (
operation: ManagedToolName,
parameters: Readonly<Record<string, JsonValue>>,
bytes: Buffer,
target: BinaryTarget,
):
| ManagedArtifactInspection
| ManagedMemberInspection
| ManagedNativeBoundaryInspection => {
if (operation === "inspect_managed_artifact") {
managedArtifactInputSchema.parse(parameters);
return inspectManagedArtifactBytes(bytes, target, {
maxMetadataBytes: MANAGED_INSPECTION_DEFAULTS.maxMetadataBytes,
maxTableRows: MANAGED_INSPECTION_DEFAULTS.maxTableRows,
maxHeapItemBytes: MANAGED_INSPECTION_DEFAULTS.maxHeapItemBytes,
});
}
if (operation === "inspect_managed_native_boundaries") {
managedNativeBoundaryInputSchema.parse(parameters);
return inspectManagedNativeBoundariesBytes(bytes, target, {
maxMetadataBytes: MANAGED_INSPECTION_DEFAULTS.maxMetadataBytes,
maxTableRows: MANAGED_INSPECTION_DEFAULTS.maxTableRows,
maxHeapItemBytes: MANAGED_INSPECTION_DEFAULTS.maxHeapItemBytes,
});
}
managedMemberInputSchema.parse(parameters);
return inspectManagedMembersBytes(bytes, target, {
maxMetadataBytes: MANAGED_INSPECTION_DEFAULTS.maxMetadataBytes,
maxTableRows: MANAGED_INSPECTION_DEFAULTS.maxTableRows,
maxHeapItemBytes: MANAGED_INSPECTION_DEFAULTS.maxHeapItemBytes,
maxMethodBodyBytes: MANAGED_INSPECTION_DEFAULTS.maxMethodBodyBytes,
maxMethodInstructions: MANAGED_INSPECTION_DEFAULTS.maxMethodInstructions,
});
};
const managedLocations = (
result:
| ManagedArtifactInspection
| ManagedMemberInspection
| ManagedNativeBoundaryInspection,
): readonly EvidenceLocation[] => {
if ("pe" in result) {
if (result.pe.cli === null) return [{ kind: "file-offset", offset: 0 }];
return [
{
kind: "file-offset-range",
start: result.pe.cli.header_offset,
end: result.pe.cli.header_offset + result.pe.cli.header_size,
},
...[result.module, result.assembly]
.filter((value) => value !== null)
.map((value) => ({
kind: "file-offset" as const,
offset: value.row_offset,
})),
];
}
if ("methods" in result)
return [
...(result.module === null
? [{ kind: "file-offset" as const, offset: 0 }]
: [{ kind: "file-offset" as const, offset: result.module.row_offset }]),
...result.methods
.filter((method) => method.body.file_offset !== null)
.slice(0, 8)
.map((method) => ({
kind: "file-offset" as const,
offset: method.body.file_offset ?? 0,
})),
];
return [
...(result.module === null
? [{ kind: "file-offset" as const, offset: 0 }]
: [{ kind: "file-offset" as const, offset: result.module.row_offset }]),
...result.pinvoke_imports.slice(0, 8).map((mapping) => ({
kind: "file-offset" as const,
offset: mapping.row_offset,
})),
];
};