fix(artifacts): bind operations to admitted sources

This commit is contained in:
morluto committed 2026-10-27 07:03:11 +08:00
1 parent ace4dbf36e
commit 2dd2ffcd45
20 files changed
+704 -238

No files matched your search

@@ -158,6 +158,7 @@ const isArtifactBundleSubject = (
subject: EvidenceSubjectTarget,
target: BinaryTarget,
): boolean =>
target.bundleInfoPlist !== undefined &&
(operation === "inventory_artifact" ||
operation === "inspect_artifact" ||
operation === "extract_artifact") &&
+8
View File
@@ -311,6 +311,10 @@ class ArtifactClient implements AnalysisClient {
const result = await extractArtifact(
{
inputPath: this.target.sourcePath ?? this.target.path,
expectedRootSha256:
this.target.bundleInfoPlist === undefined
? this.target.sha256
: undefined,
selectedPath: this.target.artifactSelectedPath,
inputFormat: this.target.format,
outputRoot: parsed.output_root,
@@ -506,6 +510,10 @@ class ArtifactClient implements AnalysisClient {
options?: ExecutionOptions,
) {
return inventoryArtifact(this.target.sourcePath ?? this.target.path, {
expectedRootSha256:
this.target.bundleInfoPlist === undefined
? this.target.sha256
: undefined,
selectedPath: this.target.artifactSelectedPath,
resourceScope: this.#resourceScope,
environment: this.environment,
+33
View File
@@ -0,0 +1,33 @@
import { realpath } from "node:fs/promises";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
/** Verify selection spelling independently of canonical descriptor ownership. */
export const assertArtifactSelectedSource = async (
canonicalPath: string,
selectedPath: string | undefined,
): Promise<void> => {
if (selectedPath === undefined) return;
const changed = (cause?: unknown) =>
new ArtifactReaderFailure(
"integrity",
`Selected artifact no longer resolves to its admitted source: ${selectedPath}`,
{
userMessage:
"The selected artifact path no longer names the opened file. Reopen the selected artifact, then retry.",
...(cause === undefined ? {} : { cause }),
},
);
let current: string;
try {
current = await realpath(selectedPath);
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
)
throw changed(cause);
throw cause;
}
if (current !== canonicalPath) throw changed();
};
+2 -6
View File
@@ -1,3 +1,4 @@
import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
@@ -437,12 +438,7 @@ export class AsarArtifactReader implements ArtifactReader {
// The packed pathname is fixed for this reader; member checks only need
// to re-observe the caller's independently selected alias.
this.#selectedCanonicalSource ??= await realpath(this.path);
const current = await realpath(selected);
if (this.#selectedCanonicalSource !== current)
throw new ArtifactReaderFailure(
"integrity",
`Selected ASAR archive no longer resolves to its admitted source: ${selected}`,
);
await assertArtifactSelectedSource(this.#selectedCanonicalSource, selected);
}
async #openUnpackedStream(
+31 -5
View File
@@ -1,3 +1,6 @@
import { assertAdmittedRootDigest } from "../inventory/classify.js";
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
import { hashStableRootArtifactHandle } from "../inventory/hashStableRootArtifact.js";
import { absoluteCallerPath } from "../../filesystem/callerPath.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
import {
@@ -56,7 +59,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
export interface ArtifactExtractionInput {
readonly asarHeaderAdmission?: AsarHeaderAdmission;
readonly inputPath: string;
/** Selected ASAR spelling; packed I/O remains on inputPath. */
readonly expectedRootSha256?: string | undefined;
/** Selected artifact spelling; packed I/O remains on inputPath. */
readonly selectedPath?: string | undefined;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
@@ -79,6 +83,8 @@ const extractArtifactInScope = async (
// Cancellation wins over every later refusal, including unsupported formats.
abortIfNeeded(signal);
const sourcePath = await realpath(input.inputPath);
if (input.expectedRootSha256 !== undefined)
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
const admission = await requireExtractableFormat(sourcePath, input, signal);
const { snapshot, zipSource } =
await scanArtifactInventoryForExtractionInScope(
@@ -86,6 +92,7 @@ const extractArtifactInScope = async (
{
resourceScope: input.resourceScope,
selectedPath: input.selectedPath,
expectedRootSha256: input.expectedRootSha256,
signal,
environment: input.environment,
asarHeaderAdmission:
@@ -176,7 +183,7 @@ export const materializeArtifactInventoryInScope = async (
)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
input.inputPath,
input.selectedPath ?? input.inputPath,
`Godot PCK directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`,
);
const selectedOccurrences = snapshot.occurrences.filter(
@@ -218,7 +225,7 @@ export const materializeArtifactInventoryInScope = async (
if (occurrence.encrypted)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
input.inputPath,
input.selectedPath ?? input.inputPath,
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
);
// Inventory retains unavailable source occurrences, but extraction cannot
@@ -377,6 +384,8 @@ const materializeSelection = async ({
traversal.snapshot(),
reader.provenance(),
);
if (input.expectedRootSha256 !== undefined)
await assertArtifactSelectedSource(sourcePath, input.selectedPath);
await output.commit(signal);
return completed;
} catch (cause: unknown) {
@@ -550,7 +559,11 @@ const requireExtractableFormat = async (
path: string,
input: Pick<
ArtifactExtractionInput,
"inputPath" | "inputFormat" | "resourceScope"
| "inputPath"
| "selectedPath"
| "inputFormat"
| "resourceScope"
| "expectedRootSha256"
>,
signal?: AbortSignal,
previous?: ArtifactEmbeddedFormatProbeFacts,
@@ -591,13 +604,26 @@ const requireExtractableFormat = async (
((format === "elf" || format === "pe" || format === "mach-o") &&
((await probes.nodeSea()) !== undefined ||
(await probes.pyInstaller())));
if (pack && input.expectedRootSha256 !== undefined) {
const digest = await hashStableRootArtifactHandle(
path,
source.handle,
source.initial,
signal,
);
assertAdmittedRootDigest(
path,
input.expectedRootSha256,
digest.sha256,
);
}
return { pack, probeFacts: probes.completedFacts() };
},
);
if (!detected.pack)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
input.inputPath,
input.selectedPath ?? input.inputPath,
`Artifact format has no extraction reader: ${format}`,
);
return { directory, probeFacts: detected.probeFacts };
+65 -13
View File
@@ -1,3 +1,4 @@
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
@@ -65,7 +66,11 @@ export const classifyAndHashRootForInventory = async (
expectedMetadata: Stats,
options: Pick<
ArtifactInventoryOptions,
"resourceScope" | "signal" | "asarHeaderAdmission" | "selectedPath"
| "resourceScope"
| "signal"
| "asarHeaderAdmission"
| "selectedPath"
| "expectedRootSha256"
>,
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
): Promise<RootInventoryClassification> => {
@@ -76,6 +81,15 @@ export const classifyAndHashRootForInventory = async (
`Root artifact changed before inventory: ${path}`,
);
}
if (directory && options.expectedRootSha256 !== undefined)
throw new ArtifactReaderFailure(
"integrity",
`Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`,
{
userMessage:
"The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.",
},
);
if (directory)
return {
format: "directory",
@@ -83,6 +97,8 @@ export const classifyAndHashRootForInventory = async (
rootSource: undefined,
readerAdmission: undefined,
};
if (options.expectedRootSha256 !== undefined)
await assertArtifactSelectedSource(path, options.selectedPath);
const source = await openRootArtifact(
path,
options.resourceScope,
@@ -101,27 +117,46 @@ export const classifyAndHashRootForInventory = async (
"integrity",
`Root artifact changed before inventory: ${path}`,
);
// Prior probe state can prove replacement without interpreting any bytes.
const probes = new ArtifactEmbeddedFormatProbes(
path,
source,
options.signal,
probeFacts,
);
const admittedDigest =
options.expectedRootSha256 === undefined
? undefined
: await hashStableRootArtifactHandle(
path,
handle,
initial,
options.signal,
);
if (admittedDigest !== undefined)
assertAdmittedRootDigest(
path,
options.expectedRootSha256,
admittedDigest.sha256,
);
const classified = await classifyRootFormat(
path,
source,
new ArtifactEmbeddedFormatProbes(
path,
source,
options.signal,
probeFacts,
),
probes,
options.signal,
options.asarHeaderAdmission,
options.selectedPath,
);
const format =
typeof classified === "string" ? classified : classified.kind;
const digest = await hashStableRootArtifactHandle(
path,
handle,
initial,
options.signal,
);
const digest =
admittedDigest ??
(await hashStableRootArtifactHandle(
path,
handle,
initial,
options.signal,
));
const retainSource =
isZipPackageFormat(format) ||
format === "mach-o-universal" ||
@@ -357,3 +392,20 @@ const readRootMzWindowsHeaderSignature = async (
if (available === 0) return undefined;
return { offset, bytes: window.subarray(0, available) };
};
/** Compare physically completed bytes before interpreting an admitted file root. */
export const assertAdmittedRootDigest = (
path: string,
expected: string | undefined,
observed: string,
): void => {
if (expected !== undefined && observed !== expected)
throw new ArtifactReaderFailure(
"integrity",
`Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`,
{
userMessage:
"The artifact changed after it was opened. Reopen the selected artifact, then retry.",
},
);
};
+2
View File
@@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
/** Options shared by artifact inventory scans. */
export interface ArtifactInventoryOptions {
readonly resourceScope: ArtifactResourceScope;
/** Identity of a previously admitted file root; directory roots have no file digest. */
readonly expectedRootSha256?: string | undefined;
/** Independently selected root spelling used only for ASAR companion lookup. */
readonly selectedPath?: string | undefined;
readonly signal?: AbortSignal | undefined;