mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 17:28:08 +00:00
feat(ntfs): seal paired journal acquisitions as Evidence
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
1 parent
c0b3c18b8a
commit
2e34aa0433
2 files changed
+273
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
artifactFilePrimaryFacts,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
projectStableArtifactFailure,
|
||||
stableArtifactInputRecovery,
|
||||
} from "../../artifacts/StableArtifactFailure.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
NTFS_JOURNAL_FILE_BYTES,
|
||||
NTFS_JOURNAL_OPERATION as operation,
|
||||
NTFS_JOURNAL_PROVIDER,
|
||||
ntfsJournalInputSchema,
|
||||
ntfsJournalResultSchema,
|
||||
} from "../../domain/windows/ntfsJournalQuery.js";
|
||||
import { composeNtfsJournalTimeline } from "../../domain/windows/ntfsJournalTimeline.js";
|
||||
import { parseMftImage } from "../../domain/windows/ntfsMft.js";
|
||||
import { parseAnalysisInput } from "../AnalysisInput.js";
|
||||
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
|
||||
|
||||
type PathParameter = "mft_path" | "journal_path";
|
||||
const inputFailure = (
|
||||
parameter: PathParameter,
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
) =>
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{
|
||||
cause,
|
||||
...(cause instanceof ArtifactReaderFailure
|
||||
? stableArtifactInputRecovery(cause)
|
||||
: {}),
|
||||
},
|
||||
[{ path: [parameter], reason: "invalid_format", message }],
|
||||
);
|
||||
|
||||
const readFailure = (
|
||||
failure: unknown,
|
||||
path: string,
|
||||
parameter: PathParameter,
|
||||
): AnalysisError => {
|
||||
const { cause } = artifactFilePrimaryFacts(failure);
|
||||
if (cause instanceof ArtifactReaderFailure) {
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(operation, {
|
||||
cause: artifactFileCancellationCause(cause),
|
||||
});
|
||||
if (cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(operation, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"file-size",
|
||||
cause.message,
|
||||
{
|
||||
maximum_file_bytes: NTFS_JOURNAL_FILE_BYTES,
|
||||
},
|
||||
{ cause, retryable: false },
|
||||
);
|
||||
}
|
||||
return inputFailure(
|
||||
parameter,
|
||||
`Could not read the selected acquisition at ${path}`,
|
||||
cause,
|
||||
);
|
||||
};
|
||||
|
||||
const readAcquisition = async (
|
||||
path: string,
|
||||
parameter: PathParameter,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
try {
|
||||
return ok(
|
||||
await resources.run(() =>
|
||||
readStableArtifact(
|
||||
path,
|
||||
NTFS_JOURNAL_FILE_BYTES,
|
||||
signal,
|
||||
undefined,
|
||||
resources,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
projectStableArtifactFailure(
|
||||
cause,
|
||||
readFailure(cause, path, parameter),
|
||||
path,
|
||||
NTFS_JOURNAL_PROVIDER.id,
|
||||
operation,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const inspectNtfsJournalEvidence = async (
|
||||
rawInput: unknown,
|
||||
resources: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const admitted = parseAnalysisInput(
|
||||
operation,
|
||||
rawInput,
|
||||
ntfsJournalInputSchema,
|
||||
signal,
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
const input = admitted.value;
|
||||
const mft = await readAcquisition(
|
||||
input.mft_path,
|
||||
"mft_path",
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
if (!mft.ok) return mft;
|
||||
const parsed = parseMftImage(mft.value.bytes);
|
||||
if (!parsed.ok) return err(inputFailure("mft_path", parsed.error.message));
|
||||
|
||||
const journal = await readAcquisition(
|
||||
input.journal_path,
|
||||
"journal_path",
|
||||
resources,
|
||||
signal,
|
||||
);
|
||||
if (!journal.ok) return journal;
|
||||
const result = ntfsJournalResultSchema.parse({
|
||||
mft: {
|
||||
path: input.mft_path,
|
||||
bytes: mft.value.bytes.length,
|
||||
sha256: mft.value.sha256,
|
||||
},
|
||||
journal: {
|
||||
path: input.journal_path,
|
||||
bytes: journal.value.bytes.length,
|
||||
sha256: journal.value.sha256,
|
||||
},
|
||||
...composeNtfsJournalTimeline(parsed.value, journal.value.bytes, {
|
||||
startFileOffset: input.start_file_offset,
|
||||
maxRows: input.max_rows,
|
||||
}),
|
||||
});
|
||||
return completeProviderEvidence(
|
||||
createEvidence(
|
||||
{
|
||||
path: input.journal_path,
|
||||
format: "file",
|
||||
sha256: journal.value.sha256,
|
||||
},
|
||||
NTFS_JOURNAL_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.ntfs-journal",
|
||||
parameters: input,
|
||||
result,
|
||||
confidence: "derived",
|
||||
limitations: [
|
||||
...result.mftLimitations,
|
||||
"Journal bytes alone do not establish the acquisition's historical completeness.",
|
||||
],
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: input.mft_path },
|
||||
{ kind: "artifact-path", path: input.journal_path },
|
||||
],
|
||||
},
|
||||
),
|
||||
signal,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { absoluteLocalPathStringSchema } from "../localPath.js";
|
||||
|
||||
export const NTFS_JOURNAL_OPERATION = "inspect_ntfs_journal" as const;
|
||||
export const NTFS_JOURNAL_PROVIDER = {
|
||||
id: "rea-ntfs-journal",
|
||||
name: "REA offline NTFS change journal",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Working-set allowance for each in-memory acquisition, independent of page size. */
|
||||
export const NTFS_JOURNAL_FILE_BYTES = 64 * 1024 * 1024;
|
||||
const offset = z.int().nonnegative();
|
||||
export const ntfsJournalRowLimitSchema = z.int().min(1).max(200).default(50);
|
||||
export const ntfsJournalInputSchema = z.strictObject({
|
||||
mft_path: absoluteLocalPathStringSchema,
|
||||
journal_path: absoluteLocalPathStringSchema,
|
||||
start_file_offset: offset.default(0),
|
||||
max_rows: ntfsJournalRowLimitSchema,
|
||||
});
|
||||
|
||||
const sourceSchema = z.strictObject({
|
||||
path: absoluteLocalPathStringSchema,
|
||||
bytes: offset,
|
||||
sha256: digestSchema,
|
||||
});
|
||||
const matchSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("matched"),
|
||||
record: z.strictObject({
|
||||
index: offset,
|
||||
sequenceNumber: offset,
|
||||
fileOffset: offset,
|
||||
}),
|
||||
}),
|
||||
z.strictObject({ kind: z.enum(["unresolved", "ambiguous"]) }),
|
||||
]);
|
||||
const issueSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("padding"),
|
||||
fileOffset: offset,
|
||||
bytes: offset,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.enum(["malformed", "truncated"]),
|
||||
fileOffset: offset,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("unsupported"),
|
||||
fileOffset: offset,
|
||||
major: offset,
|
||||
minor: offset,
|
||||
}),
|
||||
]);
|
||||
const unsignedDecimal = z.string().regex(/^(?:0|[1-9][0-9]*)$/u);
|
||||
const signedDecimal = z.string().regex(/^(?:0|-?[1-9][0-9]*)$/u);
|
||||
|
||||
export const ntfsJournalResultSchema = z.strictObject({
|
||||
mft: sourceSchema,
|
||||
journal: sourceSchema,
|
||||
rows: z.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("event"),
|
||||
event: z.strictObject({
|
||||
fileOffset: offset,
|
||||
recordBytes: offset,
|
||||
fileReference: unsignedDecimal,
|
||||
parentReference: unsignedDecimal,
|
||||
usn: signedDecimal,
|
||||
timestampFileTime: unsignedDecimal,
|
||||
reasonFlags: offset,
|
||||
sourceFlags: offset,
|
||||
securityId: offset,
|
||||
fileAttributes: offset,
|
||||
name: z.string(),
|
||||
mft: z.strictObject({ file: matchSchema, parent: matchSchema }),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("coverage"), issue: issueSchema }),
|
||||
]),
|
||||
),
|
||||
nextFileOffset: offset.nullable(),
|
||||
mftLimitations: z.array(z.string()),
|
||||
});
|
||||
Reference in new issue
Block a user