Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e636ac58e | ||
|
|
5eebf19e93 | ||
|
|
78a59d4e8f | ||
|
|
859ce82569 | ||
|
|
ae5e778de2 | ||
|
|
839f3e3d8e | ||
|
|
c1535d88ab | ||
|
|
d46449e54b | ||
|
|
1325bf936f | ||
|
|
75460dae2f | ||
|
|
d63b903535 | ||
|
|
2d6f1cd23b | ||
|
|
1a5eec54e1 | ||
|
|
fa68ca054c | ||
|
|
bd2655e6c5 | ||
|
|
38e2a342d1 | ||
|
|
670b657d0e | ||
|
|
f1f2aa3d86 | ||
|
|
c202ee9479 | ||
|
|
d93f8209d4 | ||
|
|
e45865f347 | ||
|
|
6a5cedfec4 | ||
|
|
d13143b5c5 | ||
|
|
cc706f347f | ||
|
|
96c2a93755 | ||
|
|
1652c73902 | ||
|
|
e3c15c8bbc | ||
|
|
cc78c18c47 | ||
|
|
8f91add240 | ||
|
|
33def5ae06 | ||
|
|
48813a7de9 | ||
|
|
ea88d301f8 | ||
|
|
a427ddf797 | ||
|
|
423a89e7e4 | ||
|
|
57e91187cf | ||
|
|
949ab427fb | ||
|
|
4f46836178 | ||
|
|
ce8e969112 | ||
|
|
73eb20a4b9 | ||
|
|
94243fa5a1 | ||
|
|
37824e8c46 | ||
|
|
25ca63f231 |
@@ -317,7 +317,7 @@ jobs:
|
||||
- run: npm run verify:keyed-archive
|
||||
- run: npm run verify:asset-catalog
|
||||
- run: npm run verify:macos-bundle
|
||||
- run: npm run verify:installer-package
|
||||
- run: npm run verify:code-signature
|
||||
- name: Native installer and compiled NIB hierarchy regressions
|
||||
run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
|
||||
- name: Real binary metadata and Interface Builder integration
|
||||
|
||||
@@ -398,29 +398,7 @@
|
||||
"calculated_sha256": {
|
||||
"anyOf": [{ "type": "string" }, { "type": "null" }]
|
||||
},
|
||||
"unpacked": { "type": "boolean" },
|
||||
"checksum_mismatches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"representation": {
|
||||
"type": "string",
|
||||
"enum": ["stored", "decoded"]
|
||||
},
|
||||
"algorithm": { "type": "string" },
|
||||
"declared": { "type": "string" },
|
||||
"observed": { "type": "string" }
|
||||
},
|
||||
"required": [
|
||||
"representation",
|
||||
"algorithm",
|
||||
"declared",
|
||||
"observed"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
"unpacked": { "type": "boolean" }
|
||||
},
|
||||
"required": [
|
||||
"logical_path",
|
||||
|
||||
@@ -280,12 +280,6 @@ Artifact integrity fails closed by default. A request can explicitly select
|
||||
`integrity_policy=record-and-continue` when the investigation needs verified
|
||||
siblings to continue after a mismatch.
|
||||
|
||||
Strict member failures include `details.checksum_mismatches` for conflicting
|
||||
stored or decoded checksums, preserving each algorithm and declared/observed
|
||||
value. An unsupported checksum algorithm leaves that verification facet unknown;
|
||||
readable member bytes are still hashed and can be extracted, with the declared
|
||||
algorithm and value retained in limitations.
|
||||
|
||||
Contradictory bytes are quarantined from nested expansion and recorded with
|
||||
declared and observed hashes, trust, provenance, path, and unpacked state.
|
||||
Verified siblings continue. Comparisons classify the result as a contradiction
|
||||
|
||||
+14
-8
@@ -98,14 +98,20 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
|
||||
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
|
||||
digest, every raw metadata field, pagination and malformed input rejection.
|
||||
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
|
||||
`verify:installer-package` needs only macOS's `pkgbuild`, `productbuild` and
|
||||
`pkgutil`. It builds a component package with install scripts, a product package
|
||||
around it, and a pbzx (`--compression latest`) package. It checks that
|
||||
`inspect-artifact` expands Scripts and Payload with exact content digests, and
|
||||
that the expanded Payload members equal Apple's `pkgutil --payload-files`
|
||||
listing. It also checks that the product package nests its component, that the
|
||||
pbzx payload is recorded with a limitation instead of being expanded, and that
|
||||
CLI and stdio MCP results agree.
|
||||
`verify:code-signature` needs only macOS with Command Line Tools. It re-signs
|
||||
copies of a clang-built fixture with real `codesign`: ad hoc, hardened
|
||||
runtime, hardened runtime with relaxing entitlements, and `library,restrict`
|
||||
flags. It checks that `inspect-signature` decodes those flags and derives the
|
||||
expected security facets. It also checks a platform binary, which must report a
|
||||
platform identifier and pass the `anchor apple` origin check, and bundles with an added unsealed file, at the top level
|
||||
and inside a nested XPC service, which must fail deep strict verification. CLI and stdio MCP results must match.
|
||||
The default lane explicitly reports real stapled-ticket coverage as unverified:
|
||||
its source-built ad hoc fixtures cannot receive genuine Apple notarization tickets.
|
||||
To verify ticket producer output through CLI and MCP, supply an existing stapled app:
|
||||
`npm run verify:code-signature -- --stapled-app "/Applications/Firefox.app"`.
|
||||
This optional subcase preflights `xcrun stapler`, validates the selected app with
|
||||
Apple's tool, and checks that both REA surfaces return the same ticket digest and
|
||||
`present` framing observation. It does not install an app or submit anything for notarization.
|
||||
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
|
||||
source-owned app with `clang`: a versioned framework, XPC services, an app
|
||||
extension, a login item, a privileged helper, launchd plists, and a helper tool,
|
||||
|
||||
Generated
-1
@@ -17,7 +17,6 @@
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@modelcontextprotocol/client": "2.3.1",
|
||||
"@modelcontextprotocol/server": "2.3.1",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.15.0",
|
||||
|
||||
+1
-2
@@ -142,7 +142,7 @@
|
||||
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
|
||||
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
|
||||
"verify:installer-package": "npm run build:cached && node scripts/verify/apple/installer-package.mjs",
|
||||
"verify:code-signature": "npm run build:cached && node scripts/verify/apple/code-signature.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
@@ -233,7 +233,6 @@
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@modelcontextprotocol/client": "2.3.1",
|
||||
"@modelcontextprotocol/server": "2.3.1",
|
||||
"@xmldom/xmldom": "0.9.12",
|
||||
"@xterm/addon-serialize": "0.14.0",
|
||||
"@xterm/headless": "6.0.0",
|
||||
"@zip.js/zip.js": "2.15.0",
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "../../fixtures/apple/macos-bundle.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 0 && (args.length !== 2 || args[0] !== "--stapled-app"))
|
||||
throw new Error("Usage: verify:code-signature [--stapled-app PATH]");
|
||||
const stapledApp = args[1];
|
||||
if (stapledApp !== undefined) {
|
||||
try {
|
||||
await exec("/usr/bin/xcrun", ["--find", "stapler"]);
|
||||
} catch (cause) {
|
||||
throw new Error("Real stapled-ticket verification requires xcrun stapler", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await preflightMacosBundleFixture();
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-code-signature-"));
|
||||
|
||||
const facets = (signature) =>
|
||||
Object.fromEntries(
|
||||
signature.security_facets.map(({ facet, state }) => [facet, state]),
|
||||
);
|
||||
|
||||
/** Re-sign a copy of one fixture executable ad hoc with the given options. */
|
||||
const signedCopy = async (source, name, options) => {
|
||||
const path = join(root, name);
|
||||
await copyFile(source, path);
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"codesign",
|
||||
"--force",
|
||||
"--sign",
|
||||
"-",
|
||||
...options,
|
||||
path,
|
||||
]);
|
||||
return path;
|
||||
};
|
||||
|
||||
const ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.security.cs.disable-library-validation</key><true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
|
||||
<key>com.apple.security.get-task-allow</key><true/>
|
||||
<key>com.apple.security.cs.allow-jit</key><true/>
|
||||
</dict></plist>
|
||||
`;
|
||||
|
||||
try {
|
||||
const { app } = await buildMacosBundleFixture(root);
|
||||
let realTicket = {
|
||||
status: "unverified",
|
||||
reason:
|
||||
"No known stapled app supplied; use --stapled-app PATH to exercise producer output.",
|
||||
};
|
||||
if (stapledApp !== undefined) {
|
||||
const appPath = await realpath(stapledApp);
|
||||
const oracle = await exec("/usr/bin/xcrun", [
|
||||
"stapler",
|
||||
"validate",
|
||||
appPath,
|
||||
]);
|
||||
const observed = await artifactCli("inspect-signature", appPath);
|
||||
assert.equal(
|
||||
observed.stapled_ticket.status,
|
||||
"present",
|
||||
JSON.stringify(observed.stapled_ticket),
|
||||
);
|
||||
assert.equal(observed.stapled_ticket.reason, null);
|
||||
assert.match(observed.stapled_ticket.sha256, /^[a-f0-9]{64}$/u);
|
||||
await withArtifactMcp(appPath, async (client) => {
|
||||
const viaMcp = await artifactMcpResult(client, "inspect_signature");
|
||||
assert.deepEqual(viaMcp.stapled_ticket, observed.stapled_ticket);
|
||||
});
|
||||
realTicket = {
|
||||
status: "verified",
|
||||
selected_app: stapledApp,
|
||||
ticket_sha256: observed.stapled_ticket.sha256,
|
||||
size: observed.stapled_ticket.size,
|
||||
oracle: "xcrun stapler validate",
|
||||
stdout: oracle.stdout,
|
||||
stderr: oracle.stderr,
|
||||
};
|
||||
}
|
||||
const plain = await artifactCli("inspect-signature", app);
|
||||
assert.deepEqual(plain.code_directory.flags.names, ["adhoc"]);
|
||||
assert.equal(plain.verification.status, "valid");
|
||||
// The opened bundle is reported by its canonical path.
|
||||
assert.equal(plain.verification.path, await realpath(app));
|
||||
assert.equal(plain.stapled_ticket.status, "absent");
|
||||
assert.equal(plain.sealed_resources.status, "sealed");
|
||||
assert.deepEqual(facets(plain), {
|
||||
"library-validation": "not-enforced",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "unrestricted",
|
||||
"app-sandbox": "not-sandboxed",
|
||||
});
|
||||
// Deep verification reports the fixture's real nested code, all on disk.
|
||||
assert.ok(plain.verification.validated_nested_code.length > 0);
|
||||
const checkNestedProgress = async (signature) => {
|
||||
for (const path of signature.verification.validated_nested_code) {
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch {
|
||||
assert.ok(
|
||||
signature.limitations.some((limitation) =>
|
||||
limitation.includes(JSON.stringify(path)),
|
||||
),
|
||||
`Unconfirmed progress pathname must retain its limitation: ${path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
await checkNestedProgress(plain);
|
||||
await withArtifactMcp(app, async (client) => {
|
||||
const viaMcp = await artifactMcpResult(client, "inspect_signature");
|
||||
assert.equal(viaMcp.verification.status, plain.verification.status);
|
||||
assert.equal(viaMcp.verification.path, plain.verification.path);
|
||||
await checkNestedProgress(viaMcp);
|
||||
// Separate live codesign runs can interleave progress records differently.
|
||||
// Compare the artifact's signature posture; check each capture independently.
|
||||
const semantic = ({ provenance, verification, limitations, ...posture }) =>
|
||||
posture;
|
||||
assert.deepEqual(semantic(viaMcp), semantic(plain));
|
||||
});
|
||||
|
||||
const tool = join(app, "Contents/Helpers/rea-tool");
|
||||
const hardened = await artifactCli(
|
||||
"inspect-signature",
|
||||
await signedCopy(tool, "hardened", ["--options", "runtime"]),
|
||||
);
|
||||
assert.deepEqual(hardened.code_directory.flags.names, ["adhoc", "runtime"]);
|
||||
assert.equal(hardened.stapled_ticket.status, "not-applicable");
|
||||
assert.deepEqual(facets(hardened), {
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
"executable-memory": "restricted",
|
||||
"app-sandbox": "not-sandboxed",
|
||||
});
|
||||
|
||||
const entitlements = join(root, "relaxed.entitlements");
|
||||
await writeFile(entitlements, ENTITLEMENTS);
|
||||
const relaxed = await artifactCli(
|
||||
"inspect-signature",
|
||||
await signedCopy(tool, "relaxed", [
|
||||
"--options",
|
||||
"runtime",
|
||||
"--entitlements",
|
||||
entitlements,
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(facets(relaxed), {
|
||||
"library-validation": "disabled",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "jit-allowed",
|
||||
"app-sandbox": "not-sandboxed",
|
||||
});
|
||||
|
||||
const flagged = await artifactCli(
|
||||
"inspect-signature",
|
||||
await signedCopy(tool, "flagged", ["--options", "library,restrict"]),
|
||||
);
|
||||
assert.deepEqual(flagged.code_directory.flags.names, [
|
||||
"adhoc",
|
||||
"restrict",
|
||||
"library-validation",
|
||||
]);
|
||||
assert.equal(facets(flagged)["library-validation"], "enforced");
|
||||
assert.equal(facets(flagged)["dyld-environment-variables"], "ignored");
|
||||
|
||||
const platform = await artifactCli("inspect-signature", "/usr/bin/true");
|
||||
assert.notEqual(platform.code_directory.platform_identifier, null);
|
||||
assert.equal(platform.verification.status, "valid");
|
||||
assert.equal(facets(platform)["library-validation"], "enforced");
|
||||
// The platform rules rest on a real `anchor apple` requirement check.
|
||||
assert.ok(
|
||||
platform.provenance.some(({ command }) =>
|
||||
command.includes("-R=anchor apple"),
|
||||
),
|
||||
);
|
||||
|
||||
// Negative fixtures contain only the code needed for each rejection. A large
|
||||
// nested-code graph can interleave codesign progress, making that capture
|
||||
// inconclusive independently of the invalidity these checks exercise.
|
||||
const minimalApp = async (name, includeNested) => {
|
||||
const bundle = join(root, `${name}.app`);
|
||||
const contents = join(bundle, "Contents");
|
||||
await mkdir(join(contents, "MacOS"), { recursive: true });
|
||||
await copyFile(tool, join(contents, "MacOS", "Fixture"));
|
||||
await writeFile(
|
||||
join(contents, "Info.plist"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string>
|
||||
<key>CFBundleIdentifier</key><string>com.example.rea.signature</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string></dict></plist>`,
|
||||
);
|
||||
if (includeNested) {
|
||||
await mkdir(join(contents, "XPCServices"));
|
||||
await exec("/usr/bin/ditto", [
|
||||
join(app, "Contents/XPCServices/Svc.xpc"),
|
||||
join(contents, "XPCServices/Svc.xpc"),
|
||||
]);
|
||||
}
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"codesign",
|
||||
"--force",
|
||||
"--deep",
|
||||
"--sign",
|
||||
"-",
|
||||
bundle,
|
||||
]);
|
||||
assert.equal(
|
||||
(await artifactCli("inspect-signature", bundle)).verification.status,
|
||||
"valid",
|
||||
);
|
||||
return bundle;
|
||||
};
|
||||
const nested = await minimalApp("Nested", true);
|
||||
await writeFile(
|
||||
join(nested, "Contents/XPCServices/Svc.xpc/Contents/added.txt"),
|
||||
"unsealed",
|
||||
);
|
||||
const nestedTamper = await artifactCli("inspect-signature", nested);
|
||||
assert.equal(nestedTamper.verification.status, "invalid");
|
||||
assert.ok(
|
||||
nestedTamper.verification.diagnostics.some((line) =>
|
||||
line.includes("Svc.xpc"),
|
||||
),
|
||||
JSON.stringify(nestedTamper.verification),
|
||||
);
|
||||
|
||||
const mainTamper = await minimalApp("Tampered", false);
|
||||
await mkdir(join(mainTamper, "Contents/Resources"));
|
||||
await writeFile(join(mainTamper, "Contents/Resources/added.txt"), "unsealed");
|
||||
const tampered = await artifactCli("inspect-signature", mainTamper);
|
||||
assert.equal(tampered.verification.status, "invalid");
|
||||
assert.ok(
|
||||
tampered.verification.diagnostics.some((line) => line.includes("added")),
|
||||
JSON.stringify(tampered.verification),
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
variants: [
|
||||
"adhoc",
|
||||
"runtime",
|
||||
"runtime+entitlements",
|
||||
"library,restrict",
|
||||
],
|
||||
platform_identifier: platform.code_directory.platform_identifier,
|
||||
tampered_verification: tampered.verification.status,
|
||||
nested_tamper_verification: nestedTamper.verification.status,
|
||||
real_stapled_ticket: realTicket,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
if (process.platform !== "darwin")
|
||||
throw new Error(
|
||||
"Installer package verification requires macOS pkgbuild, productbuild and pkgutil",
|
||||
);
|
||||
for (const tool of [
|
||||
"/usr/bin/pkgbuild",
|
||||
"/usr/bin/productbuild",
|
||||
"/usr/sbin/pkgutil",
|
||||
])
|
||||
try {
|
||||
await exec(tool, ["--help"]);
|
||||
} catch (cause) {
|
||||
if (cause?.code === "ENOENT")
|
||||
throw new Error(`Installer package verification requires ${tool}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-installer-package-"));
|
||||
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
/** Inventory occurrences keyed by logical path, with each node's content digest. */
|
||||
const inventoryOf = (inspection) => {
|
||||
const inventory = inspection.substeps[0].evidence.normalized_result;
|
||||
const nodes = new Map(
|
||||
inventory.nodes.map((node) => [node.artifact_id, node]),
|
||||
);
|
||||
return {
|
||||
inventory,
|
||||
entries: new Map(
|
||||
inventory.occurrences.map((occurrence) => [
|
||||
occurrence.logical_path,
|
||||
{ ...occurrence, sha256: nodes.get(occurrence.artifact_id)?.sha256 },
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Apple's Bom listing of a component package's payload, without the root. */
|
||||
const bomPaths = async (path) =>
|
||||
(await exec("/usr/sbin/pkgutil", ["--payload-files", path])).stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0 && line !== ".")
|
||||
.map((line) => line.replace(/^\.\//u, ""))
|
||||
.sort();
|
||||
|
||||
try {
|
||||
const payload = join(root, "payload");
|
||||
const scripts = join(root, "scripts");
|
||||
await mkdir(join(payload, "usr/local/rea"), { recursive: true });
|
||||
await mkdir(scripts);
|
||||
await writeFile(join(payload, "usr/local/rea/hello.txt"), "hello\n");
|
||||
for (const name of ["preinstall", "postinstall"]) {
|
||||
await writeFile(join(scripts, name), `#!/bin/sh\necho ${name}\n`);
|
||||
await chmod(join(scripts, name), 0o755);
|
||||
}
|
||||
const component = join(root, "Component.pkg");
|
||||
const build = (output, extra) =>
|
||||
exec("/usr/bin/pkgbuild", [
|
||||
"--quiet",
|
||||
"--root",
|
||||
payload,
|
||||
"--identifier",
|
||||
"com.example.rea.installer",
|
||||
"--version",
|
||||
"1.0",
|
||||
"--install-location",
|
||||
"/",
|
||||
...extra,
|
||||
output,
|
||||
]);
|
||||
await build(component, ["--scripts", scripts]);
|
||||
const product = join(root, "Product.pkg");
|
||||
await exec("/usr/bin/productbuild", [
|
||||
"--quiet",
|
||||
"--package",
|
||||
component,
|
||||
product,
|
||||
]);
|
||||
const pbzx = join(root, "Latest.pkg");
|
||||
await build(pbzx, ["--compression", "latest", "--min-os-version", "13.0"]);
|
||||
|
||||
const componentInventory = inventoryOf(
|
||||
await artifactCli("inspect-artifact", component),
|
||||
);
|
||||
assert.equal(componentInventory.inventory.manifest.root_format, "pkg");
|
||||
const payloadPaths = [...componentInventory.entries.keys()]
|
||||
.filter((path) => path.startsWith("Payload/"))
|
||||
.map((path) => path.slice("Payload/".length))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
payloadPaths,
|
||||
await bomPaths(component),
|
||||
"Payload members differ from pkgutil",
|
||||
);
|
||||
assert.equal(
|
||||
componentInventory.entries.get("Payload/usr/local/rea/hello.txt")?.sha256,
|
||||
sha256("hello\n"),
|
||||
);
|
||||
for (const name of ["preinstall", "postinstall"]) {
|
||||
const script = componentInventory.entries.get(`Scripts/${name}`);
|
||||
assert.equal(script?.sha256, sha256(`#!/bin/sh\necho ${name}\n`));
|
||||
assert.equal(script?.executable, true);
|
||||
}
|
||||
assert.ok(componentInventory.entries.has("PackageInfo"));
|
||||
assert.ok(componentInventory.entries.has("Bom"));
|
||||
|
||||
const productInventory = inventoryOf(
|
||||
await artifactCli("inspect-artifact", product),
|
||||
);
|
||||
assert.ok(productInventory.entries.has("Distribution"));
|
||||
assert.equal(
|
||||
productInventory.entries.get(
|
||||
"Component.pkg/Payload/usr/local/rea/hello.txt",
|
||||
)?.sha256,
|
||||
sha256("hello\n"),
|
||||
);
|
||||
assert.ok(productInventory.entries.has("Component.pkg/Scripts/postinstall"));
|
||||
|
||||
const pbzxInventory = inventoryOf(
|
||||
await artifactCli("inspect-artifact", pbzx),
|
||||
);
|
||||
const pbzxPayload = pbzxInventory.entries.get("Payload");
|
||||
assert.ok(
|
||||
pbzxPayload?.limitations.some((line) => line.includes("pbzx")),
|
||||
JSON.stringify(pbzxPayload),
|
||||
);
|
||||
assert.ok(
|
||||
![...pbzxInventory.entries.keys()].some((path) =>
|
||||
path.startsWith("Payload/"),
|
||||
),
|
||||
);
|
||||
|
||||
await withArtifactMcp(component, async (client) => {
|
||||
const viaMcp = inventoryOf(
|
||||
await artifactMcpResult(client, "inspect_artifact"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
viaMcp.inventory.manifest,
|
||||
componentInventory.inventory.manifest,
|
||||
);
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
mocked: false,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
payload_members: payloadPaths.length,
|
||||
matches_pkgutil_bom: true,
|
||||
product_nested: true,
|
||||
pbzx_payload: "recorded-not-expanded",
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -87,15 +87,16 @@ header, depending on the binary and analyzer version.
|
||||
## Packages and extraction
|
||||
|
||||
Use `inspect_artifact` for application bundles, archives, ZIP/APK/IPA/MSIX/AppX,
|
||||
ASAR, DMG, or PKG inputs. A PKG inventory includes Distribution, PackageInfo,
|
||||
the pre/postinstall scripts, and the gzip payload's files. Pbzx payloads are
|
||||
recorded only. Checksums declared in the PKG's table of contents are verified;
|
||||
with `integrity_policy: "record-and-continue"`, a mismatched member stays in
|
||||
the graph as `mismatched`, with the declared and observed values, and is not
|
||||
expanded. Inspect it when the artifact graph and findings help answer the
|
||||
ASAR, or DMG inputs when the artifact graph and findings help answer the
|
||||
question. It returns the complete artifact graph inline. Cite graph manifest
|
||||
IDs when using them.
|
||||
|
||||
`inspect_signature` decodes CodeDirectory flags, runs local strict
|
||||
verification, reports a stapled notarization ticket, and derives security
|
||||
facets: library validation, `DYLD_*` handling, debugger attach, executable
|
||||
memory, and sandbox. Cite each facet's `evidence`; system policy such as SIP and
|
||||
AMFI is not evaluated.
|
||||
|
||||
For an IPA or a macOS `.app`, ZIP, or DMG, pass the inventory Evidence to
|
||||
`project_apple_application_graph`. It reports application roots and nested
|
||||
bundles by path convention: app extensions, XPC services, frameworks, login
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Transform, type TransformCallback } from "node:stream";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/** Terminal exhaustion of the budget shared by all readers in one traversal. */
|
||||
export class ArtifactBudgetExceeded extends ArtifactReaderFailure {
|
||||
constructor(message: string) {
|
||||
super("limit", message);
|
||||
}
|
||||
}
|
||||
|
||||
/** One decoded-byte ceiling shared by an archive and all nested readers. */
|
||||
export class ArtifactDecodedBudget {
|
||||
#used = 0;
|
||||
#metadataUsed = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maximum = 512 * 1024 * 1024,
|
||||
private readonly maximumMetadata = 128 * 1024 * 1024,
|
||||
) {}
|
||||
|
||||
/** Charge decoding work, including repeated reads, before exposing a chunk. */
|
||||
consume(bytes: number, path: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(bytes) ||
|
||||
bytes < 0 ||
|
||||
bytes > this.maximum - this.#used
|
||||
)
|
||||
throw new ArtifactBudgetExceeded(
|
||||
`Decoded archive budget of ${this.maximum} bytes exhausted at ${path}`,
|
||||
);
|
||||
this.#used += bytes;
|
||||
}
|
||||
|
||||
/** Charge retained path text, path-trie segments, and per-entry graph/index capacity. */
|
||||
consumeEntry(path: string): void {
|
||||
const segments = path.split("/").length;
|
||||
const units = 1024 + Buffer.byteLength(path) * 2 + segments * 256;
|
||||
if (units > this.maximumMetadata - this.#metadataUsed)
|
||||
throw new ArtifactBudgetExceeded(
|
||||
`Retained archive metadata budget of ${this.maximumMetadata} capacity units exhausted at ${path}`,
|
||||
);
|
||||
this.#metadataUsed += units;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decoder-stage budget/cancellation guard, independent of member metadata. */
|
||||
export class ArtifactBudgetTransform extends Transform {
|
||||
constructor(
|
||||
private readonly budget: ArtifactDecodedBudget,
|
||||
private readonly path: string,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override _transform(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
done: TransformCallback,
|
||||
): void {
|
||||
if (this.signal?.aborted) {
|
||||
done(
|
||||
new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
`Archive decoding cancelled at ${this.path}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.budget.consume(chunk.length, this.path);
|
||||
} catch (cause: unknown) {
|
||||
done(cause instanceof Error ? cause : new Error(String(cause)));
|
||||
return;
|
||||
}
|
||||
done(null, chunk);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ export type HashResult = {
|
||||
readonly sha256: string;
|
||||
readonly bytes: number;
|
||||
readonly prefix: Buffer;
|
||||
/** Digest in the additionally requested algorithm, when one was requested. */
|
||||
readonly also?: string;
|
||||
};
|
||||
|
||||
/** Bounded classification evidence; this is not an executable format size limit. */
|
||||
@@ -29,11 +27,8 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||
export const hashReadable = async (
|
||||
stream: Readable,
|
||||
signal?: AbortSignal,
|
||||
alsoAlgorithm?: string,
|
||||
): Promise<HashResult> => {
|
||||
const hash = createHash("sha256");
|
||||
const also =
|
||||
alsoAlgorithm === undefined ? undefined : createHash(alsoAlgorithm);
|
||||
const prefixes: Buffer[] = [];
|
||||
let prefixBytes = 0;
|
||||
let bytes = 0;
|
||||
@@ -42,7 +37,6 @@ export const hashReadable = async (
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
hash.update(chunk);
|
||||
also?.update(chunk);
|
||||
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
|
||||
const selected = chunk.subarray(
|
||||
0,
|
||||
@@ -52,10 +46,5 @@ export const hashReadable = async (
|
||||
prefixBytes += selected.length;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sha256: hash.digest("hex"),
|
||||
bytes,
|
||||
prefix: Buffer.concat(prefixes),
|
||||
...(also === undefined ? {} : { also: also.digest("hex") }),
|
||||
};
|
||||
return { sha256: hash.digest("hex"), bytes, prefix: Buffer.concat(prefixes) };
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const artifactCapabilities = (
|
||||
requiresRoot: false,
|
||||
}),
|
||||
limitations: Object.freeze([
|
||||
"DMG child inventory automatically uses a read-only native macOS mount. PKG (xar) inventory expands gzip-cpio Scripts and Payload members; pbzx payloads are recorded without expansion.",
|
||||
"DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",
|
||||
"ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only.",
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
|
||||
import type { ArtifactDecodedBudget } from "./ArtifactDecodedBudget.js";
|
||||
import type {
|
||||
ArtifactChecksumObservation,
|
||||
ArtifactIntegrityFailureDetails,
|
||||
} from "../domain/artifactOperationError.js";
|
||||
export type { ArtifactChecksumObservation } from "../domain/artifactOperationError.js";
|
||||
|
||||
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
|
||||
export interface ArtifactEntry {
|
||||
@@ -21,21 +15,6 @@ export interface ArtifactEntry {
|
||||
readonly unpacked: boolean;
|
||||
readonly limitations: readonly string[];
|
||||
readonly adapterKey: string;
|
||||
/**
|
||||
* Container checksum of the member's bytes in an algorithm other than
|
||||
* SHA-256 (xar declares SHA-1 or MD5). The scan verifies it under the
|
||||
* caller's integrity policy.
|
||||
*/
|
||||
readonly declaredChecksum?: {
|
||||
readonly algorithm: "sha1" | "md5" | "sha512";
|
||||
readonly value: string;
|
||||
};
|
||||
/** The container records this member but does not hold its bytes, such as an unresolved hard link. */
|
||||
readonly contentUnavailable?: boolean;
|
||||
/** A consumed representation contradicted its declared integrity metadata. */
|
||||
readonly integrityMismatched?: boolean;
|
||||
/** Archive format of this member's own bytes, when the scan should expand it. */
|
||||
readonly nestedArchive?: "gzip-cpio";
|
||||
/** Filesystem identity captured during traversal, when supplied by an adapter. */
|
||||
readonly sourceIdentity?: {
|
||||
readonly device: number;
|
||||
@@ -45,15 +24,9 @@ export interface ArtifactEntry {
|
||||
|
||||
/** Read-only adapter over one directory, archive, or virtual container. */
|
||||
export interface ArtifactReader {
|
||||
readonly format: "directory" | ZipPackageFormat | "asar" | "pkg" | "file";
|
||||
/** Shared safety budget for compressed representations and nested archives. */
|
||||
readonly decodedBudget?: ArtifactDecodedBudget;
|
||||
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
|
||||
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
|
||||
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
|
||||
/** Checksums observed after the complete member stream has been consumed. */
|
||||
integrityObservations?(
|
||||
entry: ArtifactEntry,
|
||||
): readonly ArtifactChecksumObservation[];
|
||||
provenance(): readonly ArtifactCommand[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -71,7 +44,12 @@ export class ArtifactReaderFailure extends Error {
|
||||
| "unavailable",
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
readonly details?: ArtifactIntegrityFailureDetails,
|
||||
readonly details?: Readonly<{
|
||||
logicalPath: string;
|
||||
declaredSha256: string | null;
|
||||
calculatedSha256: string | null;
|
||||
unpacked: boolean;
|
||||
}>,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "ArtifactReaderFailure";
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { expect, it } from "vitest";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { artifactStreamPipeline } from "./ArtifactStreamPipeline.js";
|
||||
|
||||
it("preserves tagged failures from intermediate verification stages", async () => {
|
||||
const failure = new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"stored checksum disagrees",
|
||||
);
|
||||
const verifier = new Transform({
|
||||
transform(_chunk, _encoding, done) {
|
||||
done(failure);
|
||||
},
|
||||
});
|
||||
const output = artifactStreamPipeline(
|
||||
Readable.from([Buffer.from("encoded")]),
|
||||
[verifier],
|
||||
{ path: "member" },
|
||||
);
|
||||
await expect(buffer(output)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("closes the source when the consumer stops before exhausting member data", async () => {
|
||||
const source = Readable.from(
|
||||
(function* () {
|
||||
for (let index = 0; index < 1000; index++) yield Buffer.alloc(64 * 1024);
|
||||
})(),
|
||||
);
|
||||
const closed = new Promise<void>((resolve) =>
|
||||
source.once("close", () => resolve()),
|
||||
);
|
||||
const output = artifactStreamPipeline(source, [], { path: "member" });
|
||||
for await (const chunk of output) {
|
||||
expect(Buffer.isBuffer(chunk)).toBe(true);
|
||||
break;
|
||||
}
|
||||
await closed;
|
||||
expect(source.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("observes abort before a consumer starts reading", async () => {
|
||||
const controller = new AbortController();
|
||||
const source = Readable.from([Buffer.from("data")]);
|
||||
const output = artifactStreamPipeline(source, [], {
|
||||
path: "member",
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await expect(buffer(output)).rejects.toMatchObject({ reason: "cancelled" });
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { PassThrough, Readable, pipeline, type Transform } from "node:stream";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/**
|
||||
* Connect a decoder chain as one lifecycle: failures propagate in both
|
||||
* directions, cancellation destroys every stage, and consumer early-return
|
||||
* cannot leave the archive source reading after its handle is closed.
|
||||
*/
|
||||
export const artifactStreamPipeline = (
|
||||
source: Readable,
|
||||
stages: readonly Transform[],
|
||||
context: {
|
||||
readonly path: string;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
},
|
||||
): Readable => {
|
||||
const output = new PassThrough();
|
||||
const aborted = () =>
|
||||
output.destroy(
|
||||
new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
`Archive read cancelled: ${context.path}`,
|
||||
),
|
||||
);
|
||||
pipeline([source, ...stages, output], () => {
|
||||
// The readable destination retains any failure for its consumer. Keep
|
||||
// cancellation active until buffered output has also been consumed.
|
||||
});
|
||||
context.signal?.addEventListener("abort", aborted, { once: true });
|
||||
if (context.signal?.aborted) aborted();
|
||||
const dispose = () => {
|
||||
context.signal?.removeEventListener("abort", aborted);
|
||||
output.destroy();
|
||||
};
|
||||
const consumer = Readable.from(
|
||||
(async function* () {
|
||||
try {
|
||||
for await (const chunk of output) yield chunk;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
if (context.signal?.aborted)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
`Archive read cancelled: ${context.path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Archive member could not be decoded: ${context.path}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
// Also covers destroy() before the async generator is first advanced.
|
||||
consumer.once("close", dispose);
|
||||
return consumer;
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { normalizeArtifactPath } from "./ArtifactPaths.js";
|
||||
import type { ArtifactEntry, ArtifactReader } from "./ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "./AsarArtifactReader.js";
|
||||
import { CpioArtifactReader } from "./CpioArtifactReader.js";
|
||||
|
||||
/** One reader entry plus its stable logical path and expansion eligibility. */
|
||||
export interface ArtifactTreeEntry {
|
||||
readonly reader: ArtifactReader;
|
||||
readonly entry: ArtifactEntry;
|
||||
readonly path: string;
|
||||
readonly container: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared inventory/extraction traversal. The visitor decides whether verified
|
||||
* content may expand; this boundary owns nested paths, reader creation and
|
||||
* cleanup so the two workflows cannot disagree about container topology.
|
||||
*/
|
||||
export const visitArtifactTree = async (
|
||||
root: ArtifactReader,
|
||||
visit: (item: ArtifactTreeEntry) => Promise<boolean>,
|
||||
options: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly integrity?: "fail" | "record-and-continue";
|
||||
readonly expandAsar?: boolean;
|
||||
} = {},
|
||||
): Promise<void> => {
|
||||
const stack = [
|
||||
{
|
||||
reader: root,
|
||||
prefix: "",
|
||||
iterator: root.entries(options.signal)[Symbol.asyncIterator](),
|
||||
owned: false,
|
||||
},
|
||||
];
|
||||
try {
|
||||
while (stack.length > 0) {
|
||||
const frame = stack.at(-1);
|
||||
if (frame === undefined) break;
|
||||
const next = await frame.iterator.next();
|
||||
if (next.done === true) {
|
||||
stack.pop();
|
||||
if (frame.owned) await frame.reader.close();
|
||||
continue;
|
||||
}
|
||||
const entry = next.value;
|
||||
// Charge the full retained namespace, including all enclosing prefixes,
|
||||
// independently from each reader's own raw metadata/index charge.
|
||||
const combined =
|
||||
frame.prefix === "" ? entry.path : `${frame.prefix}/${entry.path}`;
|
||||
root.decodedBudget?.consumeEntry(combined);
|
||||
const path = normalizeArtifactPath(combined);
|
||||
const cpio = entry.nestedArchive === "gzip-cpio";
|
||||
const asar =
|
||||
options.expandAsar !== false &&
|
||||
entry.kind === "file" &&
|
||||
path.toLowerCase().endsWith(".asar") &&
|
||||
isAbsolute(entry.adapterKey);
|
||||
const container =
|
||||
!entry.encrypted && entry.contentUnavailable !== true && (cpio || asar);
|
||||
const expand = await visit({
|
||||
reader: frame.reader,
|
||||
entry,
|
||||
path,
|
||||
container,
|
||||
});
|
||||
if (!container || !expand) continue;
|
||||
const parent = frame.reader;
|
||||
const nested = cpio
|
||||
? new CpioArtifactReader(
|
||||
(signal) => parent.open(entry, signal),
|
||||
options.integrity ?? "fail",
|
||||
parent.decodedBudget,
|
||||
)
|
||||
: new AsarArtifactReader(entry.adapterKey);
|
||||
stack.push({
|
||||
reader: nested,
|
||||
prefix: path,
|
||||
iterator: nested.entries(options.signal)[Symbol.asyncIterator](),
|
||||
owned: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled(
|
||||
stack
|
||||
.filter(({ owned }) => owned)
|
||||
.map(async ({ reader, iterator }) => {
|
||||
await iterator.return?.();
|
||||
await reader.close();
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,804 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
ArtifactBudgetTransform,
|
||||
ArtifactDecodedBudget,
|
||||
} from "./ArtifactDecodedBudget.js";
|
||||
import { createGunzip } from "node:zlib";
|
||||
import { artifactStreamPipeline } from "./ArtifactStreamPipeline.js";
|
||||
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactChecksumObservation,
|
||||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
|
||||
/** Longest member name accepted, including its terminating NUL. */
|
||||
const MAX_NAME_BYTES = 64 * 1024;
|
||||
/** Hard-link bytes are buffered to serve every linked path, within these bounds. */
|
||||
const MAX_LINK_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_LINK_BUFFER_TOTAL = 64 * 1024 * 1024;
|
||||
/** Longest symlink target read; PATH_MAX is 1024 on macOS and 4096 on Linux. */
|
||||
const MAX_SYMLINK_TARGET_BYTES = 4096;
|
||||
const TRAILER = "TRAILER!!!";
|
||||
const S_IFMT = 0o170000;
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFREG = 0o100000;
|
||||
const S_IFLNK = 0o120000;
|
||||
|
||||
const UNRESOLVED_LINK =
|
||||
"Hard-link bytes are stored with another member of this archive and could not be associated with this path.";
|
||||
|
||||
/** Pull exactly the requested bytes from a stream, holding at most one chunk. */
|
||||
class ByteSource {
|
||||
readonly #iterator: AsyncIterator<unknown>;
|
||||
#buffer: Buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(stream: Readable) {
|
||||
this.#iterator = stream[Symbol.asyncIterator]();
|
||||
}
|
||||
|
||||
async #fill(): Promise<boolean> {
|
||||
let next: IteratorResult<unknown>;
|
||||
try {
|
||||
next = await this.#iterator.next();
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio stream is not valid gzip data",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
if (next.done === true) return false;
|
||||
if (!Buffer.isBuffer(next.value))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio stream yielded non-binary data",
|
||||
);
|
||||
this.#buffer =
|
||||
this.#buffer.length === 0
|
||||
? next.value
|
||||
: Buffer.concat([this.#buffer, next.value]);
|
||||
return true;
|
||||
}
|
||||
|
||||
async read(length: number, label: string): Promise<Buffer> {
|
||||
while (this.#buffer.length < length)
|
||||
if (!(await this.#fill()))
|
||||
throw new ArtifactReaderFailure("format", `cpio ${label} is truncated`);
|
||||
const bytes = this.#buffer.subarray(0, length);
|
||||
this.#buffer = this.#buffer.subarray(length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Consume the NUL alignment bytes required by newc and crc records. */
|
||||
async padding(length: number, label: string): Promise<void> {
|
||||
if ((await this.read(length, label)).some((byte) => byte !== 0))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`cpio ${label} contains nonzero bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Yield `length` bytes in chunks without buffering them together. */
|
||||
async *take(length: number, label: string): AsyncGenerator<Buffer> {
|
||||
let remaining = length;
|
||||
while (remaining > 0) {
|
||||
if (this.#buffer.length === 0 && !(await this.#fill()))
|
||||
throw new ArtifactReaderFailure("format", `cpio ${label} is truncated`);
|
||||
const size = Math.min(remaining, this.#buffer.length);
|
||||
const chunk = this.#buffer.subarray(0, size);
|
||||
this.#buffer = this.#buffer.subarray(size);
|
||||
remaining -= size;
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.#iterator.return?.();
|
||||
}
|
||||
|
||||
/** Consume legal zero padding through decoder EOF, including gzip trailer verification. */
|
||||
async finish(): Promise<void> {
|
||||
do {
|
||||
if (this.#buffer.some((byte) => byte !== 0))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio has non-padding bytes after its trailer",
|
||||
);
|
||||
this.#buffer = Buffer.alloc(0);
|
||||
} while (await this.#fill());
|
||||
}
|
||||
}
|
||||
|
||||
interface CpioHeader {
|
||||
readonly format: "odc" | "newc" | "crc";
|
||||
readonly mode: number;
|
||||
readonly fileSize: number;
|
||||
readonly nameSize: number;
|
||||
readonly links: number;
|
||||
/** Device and inode identifying hard links. */
|
||||
readonly identity: string;
|
||||
/** `c_check` of `070702` archives: the unsigned 32-bit sum of the data bytes. */
|
||||
readonly check: number;
|
||||
}
|
||||
|
||||
const field = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
width: number,
|
||||
radix: 8 | 16,
|
||||
): number => {
|
||||
const text = bytes.toString("latin1", start, start + width);
|
||||
const pattern = radix === 8 ? /^[0-7]+$/u : /^[0-9a-fA-F]+$/u;
|
||||
const value = pattern.test(text) ? Number.parseInt(text, radix) : Number.NaN;
|
||||
if (!Number.isSafeInteger(value))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio header has a malformed numeric field",
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
/** odc (`070707`, octal) and newc/crc (`070701`/`070702`, hexadecimal) headers. */
|
||||
const readHeader = async (source: ByteSource): Promise<CpioHeader> => {
|
||||
const magic = (await source.read(6, "header")).toString("latin1");
|
||||
if (magic === "070707") {
|
||||
const rest = await source.read(70, "header");
|
||||
return {
|
||||
format: "odc",
|
||||
identity: `${field(rest, 0, 6, 8)}:${field(rest, 6, 6, 8)}`,
|
||||
mode: field(rest, 12, 6, 8),
|
||||
links: field(rest, 30, 6, 8),
|
||||
nameSize: field(rest, 53, 6, 8),
|
||||
fileSize: field(rest, 59, 11, 8),
|
||||
check: 0,
|
||||
};
|
||||
}
|
||||
if (magic === "070701" || magic === "070702") {
|
||||
const rest = await source.read(104, "header");
|
||||
return {
|
||||
format: magic === "070702" ? "crc" : "newc",
|
||||
identity: `${field(rest, 56, 8, 16)}:${field(rest, 64, 8, 16)}:${field(rest, 0, 8, 16)}`,
|
||||
mode: field(rest, 8, 8, 16),
|
||||
links: field(rest, 32, 8, 16),
|
||||
fileSize: field(rest, 48, 8, 16),
|
||||
nameSize: field(rest, 88, 8, 16),
|
||||
check: field(rest, 96, 8, 16),
|
||||
};
|
||||
}
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio member has an unsupported header; only odc and newc archives are expanded",
|
||||
);
|
||||
};
|
||||
|
||||
const padding = (format: CpioHeader["format"], length: number): number =>
|
||||
format === "odc" ? 0 : (4 - (length % 4)) % 4;
|
||||
|
||||
/** Normalize a member name; reject absolute and traversing names. */
|
||||
const memberPath = (raw: string): string | undefined => {
|
||||
const segments = raw
|
||||
.split("/")
|
||||
.filter((segment) => segment !== "" && segment !== ".");
|
||||
if (raw.startsWith("/") || segments.includes(".."))
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`cpio member escapes its archive: ${raw}`,
|
||||
);
|
||||
return segments.length === 0 ? undefined : segments.join("/");
|
||||
};
|
||||
|
||||
/** Project one cpio member as an archive-neutral entry. */
|
||||
const entryOf = (member: {
|
||||
readonly path: string;
|
||||
readonly kind: ArtifactEntry["kind"];
|
||||
readonly key: string;
|
||||
readonly header: CpioHeader;
|
||||
readonly limitations: readonly string[];
|
||||
readonly size?: number | null;
|
||||
readonly contentUnavailable?: boolean;
|
||||
readonly integrityMismatched?: boolean;
|
||||
}): ArtifactEntry => ({
|
||||
path: member.path,
|
||||
kind: member.kind,
|
||||
declaredSize:
|
||||
member.size !== undefined
|
||||
? member.size
|
||||
: member.kind === "file"
|
||||
? member.header.fileSize
|
||||
: null,
|
||||
compressedSize: null,
|
||||
executable: (member.header.mode & 0o111) !== 0,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256: null,
|
||||
unpacked: false,
|
||||
limitations: member.limitations,
|
||||
adapterKey: member.key,
|
||||
...(member.contentUnavailable === true ? { contentUnavailable: true } : {}),
|
||||
...(member.integrityMismatched === true ? { integrityMismatched: true } : {}),
|
||||
});
|
||||
|
||||
interface PendingLink {
|
||||
readonly path: string;
|
||||
readonly key: string;
|
||||
readonly header: CpioHeader;
|
||||
}
|
||||
|
||||
const drain = async (chunks: AsyncIterable<Buffer>): Promise<void> => {
|
||||
for await (const chunk of chunks) void chunk;
|
||||
};
|
||||
|
||||
const crcObservation = (
|
||||
declared: number,
|
||||
observed: number,
|
||||
): ArtifactChecksumObservation => ({
|
||||
representation: "decoded",
|
||||
algorithm: "cpio-byte-sum",
|
||||
declared: declared.toString(16).padStart(8, "0"),
|
||||
observed: observed.toString(16).padStart(8, "0"),
|
||||
});
|
||||
|
||||
/** Same sentence the scanner uses for a streamed regular-file CRC contradiction. */
|
||||
const crcMismatchDetail = (observation: ArtifactChecksumObservation): string =>
|
||||
`Declared ${observation.representation} ${observation.algorithm} ${observation.declared} disagrees with observed ${observation.observed}.`;
|
||||
|
||||
/** Human-readable limitation for a collected (or skipped) symlink target. */
|
||||
const symlinkTargetLimitation = (
|
||||
header: CpioHeader,
|
||||
target: Buffer | undefined,
|
||||
): string => {
|
||||
if (target === undefined)
|
||||
return `Symlink target of ${header.fileSize} bytes exceeds ${MAX_SYMLINK_TARGET_BYTES} bytes and was not read.`;
|
||||
try {
|
||||
return `Symlink target: ${new TextDecoder("utf-8", { fatal: true }).decode(target)}`;
|
||||
} catch {
|
||||
return "Symlink target is not valid UTF-8 and was not decoded; the archived bytes are preserved only as a byte count.";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sequential reader for a gzip-compressed cpio archive, such as an installer
|
||||
* package's Scripts or Payload. Members are decompressed once, in order: open
|
||||
* an entry before advancing to the next one. `070702` data is checked against
|
||||
* its CRC. Hard links whose bytes are stored on another member are served
|
||||
* from that member's bytes; device nodes, FIFOs, and sockets are skipped.
|
||||
*/
|
||||
export class CpioArtifactReader implements ArtifactReader {
|
||||
readonly format = "file" as const;
|
||||
#source: ByteSource | undefined;
|
||||
#signal: AbortSignal | undefined;
|
||||
#current:
|
||||
| {
|
||||
readonly key: string;
|
||||
readonly path: string;
|
||||
readonly header: CpioHeader;
|
||||
}
|
||||
| undefined;
|
||||
#consumed = false;
|
||||
readonly #links = new Map<string, Buffer>();
|
||||
readonly #pending = new Map<string, PendingLink[]>();
|
||||
readonly #aliases = new Map<string, Buffer>();
|
||||
readonly #integrity = new Map<
|
||||
string,
|
||||
readonly ArtifactChecksumObservation[]
|
||||
>();
|
||||
readonly #linkIntegrity = new Map<
|
||||
string,
|
||||
readonly ArtifactChecksumObservation[]
|
||||
>();
|
||||
#buffered = 0;
|
||||
|
||||
constructor(
|
||||
private readonly openCompressed: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Readable>,
|
||||
/**
|
||||
* Integrity mode for member CRC failures. Every member representation is
|
||||
* checked before the next header is read. `fail` throws. Under
|
||||
* record-and-continue the disagreement is returned as checksum evidence
|
||||
* so the occurrence and later siblings are still inventoried.
|
||||
*/
|
||||
private readonly integrity: "fail" | "record-and-continue" = "fail",
|
||||
readonly decodedBudget: ArtifactDecodedBudget = new ArtifactDecodedBudget(),
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
this.#signal = signal;
|
||||
const compressed = await this.openCompressed(signal);
|
||||
const source = new ByteSource(
|
||||
artifactStreamPipeline(
|
||||
compressed,
|
||||
[
|
||||
createGunzip(),
|
||||
new ArtifactBudgetTransform(this.decodedBudget, "gzip-cpio", signal),
|
||||
],
|
||||
{ path: "gzip-cpio", signal },
|
||||
),
|
||||
);
|
||||
this.#source = source;
|
||||
for (let index = 0; ; index++) {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"cpio expansion was cancelled",
|
||||
);
|
||||
yield* this.#finishCurrent(source);
|
||||
const header = await readHeader(source);
|
||||
if (header.nameSize < 1 || header.nameSize > MAX_NAME_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio member name size is invalid",
|
||||
);
|
||||
const nameBytes = await source.read(header.nameSize, "member name");
|
||||
await source.padding(
|
||||
padding(header.format, 110 + header.nameSize),
|
||||
"name padding",
|
||||
);
|
||||
// cpio names require exactly one terminal NUL: a missing terminator or
|
||||
// an embedded NUL with trailing bytes is malformed. Decode without
|
||||
// replacement so distinct byte names cannot collapse to one path.
|
||||
if (
|
||||
nameBytes.length === 0 ||
|
||||
nameBytes[nameBytes.length - 1] !== 0 ||
|
||||
nameBytes.indexOf(0) !== nameBytes.length - 1
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio member name is not NUL-terminated",
|
||||
);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
nameBytes.subarray(0, nameBytes.length - 1),
|
||||
);
|
||||
} catch {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio member name is not valid UTF-8",
|
||||
);
|
||||
}
|
||||
this.decodedBudget.consumeEntry(raw);
|
||||
if (raw === TRAILER) {
|
||||
if (header.fileSize !== 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"cpio trailer declares member data",
|
||||
);
|
||||
await drain(this.#verified(source, header, raw, undefined, "fail"));
|
||||
await source.finish();
|
||||
yield* this.#unresolvedLinks();
|
||||
return;
|
||||
}
|
||||
yield* this.#member(source, header, raw, `${index}:${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
async *#member(
|
||||
source: ByteSource,
|
||||
header: CpioHeader,
|
||||
raw: string,
|
||||
key: string,
|
||||
): AsyncGenerator<ArtifactEntry> {
|
||||
const type = header.mode & S_IFMT;
|
||||
const path = memberPath(raw);
|
||||
if (type === S_IFDIR) {
|
||||
const consumed = await this.#consume(source, header, raw, false);
|
||||
if (path !== undefined)
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "directory",
|
||||
key,
|
||||
header,
|
||||
limitations:
|
||||
consumed.mismatch === undefined
|
||||
? []
|
||||
: [crcMismatchDetail(consumed.mismatch)],
|
||||
integrityMismatched: consumed.mismatch !== undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === S_IFLNK) {
|
||||
yield* this.#symlink(source, { header, raw, key }, path);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
path !== undefined &&
|
||||
type === S_IFREG &&
|
||||
header.links > 1 &&
|
||||
header.fileSize === 0
|
||||
) {
|
||||
// Zero-size headers carry no bytes, so a CRC archive must declare zero.
|
||||
// Under record-and-continue this is the occurrence's own forgotten
|
||||
// bytes: yield it unavailable so later siblings still inventory.
|
||||
const consumed = await this.#consume(source, header, raw, false);
|
||||
if (consumed.mismatch !== undefined) {
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
size: null,
|
||||
limitations: [crcMismatchDetail(consumed.mismatch)],
|
||||
contentUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const stored = this.#links.get(header.identity);
|
||||
if (stored !== undefined) {
|
||||
yield this.#alias(path, key, header, stored);
|
||||
return;
|
||||
}
|
||||
// newc stores a hard link's bytes on its last member; wait for them.
|
||||
// An all-empty group is resolved to empty files at TRAILER!!!.
|
||||
const waiting = this.#pending.get(header.identity) ?? [];
|
||||
waiting.push({ path, key, header });
|
||||
this.#pending.set(header.identity, waiting);
|
||||
return;
|
||||
}
|
||||
this.#current = { key, path: raw, header };
|
||||
this.#consumed = false;
|
||||
if (path === undefined) return;
|
||||
if (type !== S_IFREG) {
|
||||
// FIFOs, device nodes, sockets and other types are not expanded; keep
|
||||
// an explicit unavailable occurrence instead of dropping the path.
|
||||
const consumed = await this.#consume(source, header, raw, false);
|
||||
this.#current = undefined;
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
size: null,
|
||||
limitations: [
|
||||
`Unsupported cpio member type ${type.toString(8)}; content not expanded.`,
|
||||
...(consumed.mismatch === undefined
|
||||
? []
|
||||
: [crcMismatchDetail(consumed.mismatch)]),
|
||||
],
|
||||
contentUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One symlink member. A hostile header can declare a multi-gigabyte target,
|
||||
* so oversized targets stream past. The CRC is resolved before the entry is
|
||||
* yielded; under record-and-continue a disagreement stays on the occurrence
|
||||
* so later siblings are still inventoried.
|
||||
*/
|
||||
async *#symlink(
|
||||
source: ByteSource,
|
||||
member: {
|
||||
readonly header: CpioHeader;
|
||||
readonly raw: string;
|
||||
readonly key: string;
|
||||
},
|
||||
path: string | undefined,
|
||||
): AsyncGenerator<ArtifactEntry> {
|
||||
const { header, raw, key } = member;
|
||||
const consumed = await this.#consume(
|
||||
source,
|
||||
header,
|
||||
raw,
|
||||
header.fileSize <= MAX_SYMLINK_TARGET_BYTES,
|
||||
);
|
||||
if (path === undefined) return;
|
||||
if (consumed.mismatch !== undefined) {
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "symlink",
|
||||
key,
|
||||
header,
|
||||
limitations: [
|
||||
crcMismatchDetail(consumed.mismatch),
|
||||
symlinkTargetLimitation(header, consumed.bytes),
|
||||
],
|
||||
contentUnavailable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "symlink",
|
||||
key,
|
||||
header,
|
||||
limitations: [symlinkTargetLimitation(header, consumed.bytes)],
|
||||
});
|
||||
}
|
||||
|
||||
#alias(
|
||||
path: string,
|
||||
key: string,
|
||||
header: CpioHeader,
|
||||
bytes: Buffer,
|
||||
): ArtifactEntry {
|
||||
this.#aliases.set(key, bytes);
|
||||
const sourceIntegrity = this.#linkIntegrity.get(header.identity);
|
||||
if (sourceIntegrity !== undefined)
|
||||
this.#integrity.set(key, sourceIntegrity);
|
||||
return entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
size: bytes.length,
|
||||
limitations: [
|
||||
"Hard link: bytes are stored with another member of this archive.",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
*#unresolvedLinks(): Generator<ArtifactEntry> {
|
||||
for (const links of this.#pending.values())
|
||||
for (const { path, key, header } of links) {
|
||||
// A complete zero-length group establishes empty content. An orphan
|
||||
// that claims more links than appear is missing its bytes. Pending
|
||||
// groups contain only zero-length members; do not rescan per alias.
|
||||
const complete = links.length >= header.links;
|
||||
if (header.fileSize === 0 && complete) {
|
||||
const empty = Buffer.alloc(0);
|
||||
this.#aliases.set(key, empty);
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
size: 0,
|
||||
limitations: [
|
||||
"Hard link: every member of this link group is empty.",
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
yield entryOf({
|
||||
path,
|
||||
kind: "file",
|
||||
key,
|
||||
header,
|
||||
size: null,
|
||||
limitations: [UNRESOLVED_LINK],
|
||||
contentUnavailable: true,
|
||||
});
|
||||
}
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
/** Finish the current member, then serve hard links that were waiting for it. */
|
||||
async *#finishCurrent(source: ByteSource): AsyncGenerator<ArtifactEntry> {
|
||||
const current = this.#current;
|
||||
if (current === undefined) return;
|
||||
this.#current = undefined;
|
||||
if (!this.#consumed) {
|
||||
if (this.#shouldKeep(current.header))
|
||||
this.#remember(
|
||||
current.header,
|
||||
await this.#collect(source, current.header, current.path),
|
||||
);
|
||||
else
|
||||
await drain(
|
||||
this.#verified(
|
||||
source,
|
||||
current.header,
|
||||
current.path,
|
||||
undefined,
|
||||
"fail",
|
||||
),
|
||||
);
|
||||
}
|
||||
const waiting = this.#pending.get(current.header.identity);
|
||||
const stored = this.#links.get(current.header.identity);
|
||||
if (waiting === undefined || stored === undefined) return;
|
||||
this.#pending.delete(current.header.identity);
|
||||
for (const { path, key, header } of waiting)
|
||||
yield this.#alias(path, key, header, stored);
|
||||
}
|
||||
|
||||
#shouldKeep(header: CpioHeader): boolean {
|
||||
return (
|
||||
(header.mode & S_IFMT) === S_IFREG &&
|
||||
header.links > 1 &&
|
||||
header.fileSize > 0 &&
|
||||
header.fileSize <= MAX_LINK_BUFFER_BYTES &&
|
||||
this.#buffered + header.fileSize <= MAX_LINK_BUFFER_TOTAL
|
||||
);
|
||||
}
|
||||
|
||||
#remember(
|
||||
header: CpioHeader,
|
||||
bytes: Buffer,
|
||||
observations: readonly ArtifactChecksumObservation[] = [],
|
||||
): void {
|
||||
if (this.#links.has(header.identity)) return;
|
||||
this.#links.set(header.identity, bytes);
|
||||
if (observations.length > 0)
|
||||
this.#linkIntegrity.set(header.identity, observations);
|
||||
this.#buffered += bytes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member bytes and padding. `sum` receives the unsigned 32-bit total used
|
||||
* by `070702`. Padding is consumed before the caller reports a mismatch, so
|
||||
* a recovered archive stays aligned on the next header.
|
||||
*/
|
||||
async *#payload(
|
||||
source: ByteSource,
|
||||
header: CpioHeader,
|
||||
sum: { value: number },
|
||||
): AsyncGenerator<Buffer> {
|
||||
this.#cancelled();
|
||||
for await (const chunk of source.take(header.fileSize, "member data")) {
|
||||
this.#cancelled();
|
||||
if (header.format === "crc")
|
||||
for (const byte of chunk) sum.value = (sum.value + byte) >>> 0;
|
||||
yield chunk;
|
||||
}
|
||||
await source.padding(
|
||||
padding(header.format, header.fileSize),
|
||||
"data padding",
|
||||
);
|
||||
this.#cancelled();
|
||||
}
|
||||
|
||||
/**
|
||||
* One CRC check for every member kind. `fail` throws with the observation
|
||||
* in the message. record-and-continue returns the observation so callers
|
||||
* keep declared and observed values instead of catching a string.
|
||||
*/
|
||||
#crcMismatch(
|
||||
header: CpioHeader,
|
||||
path: string,
|
||||
sum: number,
|
||||
policy: "fail" | "record-and-continue" = this.integrity,
|
||||
): ArtifactChecksumObservation | undefined {
|
||||
if (header.format !== "crc" || sum === header.check) return undefined;
|
||||
const observation = crcObservation(header.check, sum);
|
||||
if (policy === "fail")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`cpio CRC disagrees with content: ${path} (${crcMismatchDetail(observation)})`,
|
||||
);
|
||||
return observation;
|
||||
}
|
||||
|
||||
/** Read one member fully. `collect` retains bytes for symlinks and hard links. */
|
||||
async #consume(
|
||||
source: ByteSource,
|
||||
header: CpioHeader,
|
||||
path: string,
|
||||
collect: boolean,
|
||||
policy?: "fail" | "record-and-continue",
|
||||
): Promise<{
|
||||
readonly bytes: Buffer | undefined;
|
||||
readonly mismatch: ArtifactChecksumObservation | undefined;
|
||||
}> {
|
||||
const sum = { value: 0 };
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of this.#payload(source, header, sum))
|
||||
if (collect) chunks.push(chunk);
|
||||
return {
|
||||
bytes: collect ? Buffer.concat(chunks) : undefined,
|
||||
mismatch: this.#crcMismatch(header, path, sum.value, policy),
|
||||
};
|
||||
}
|
||||
|
||||
/** Member data and its padding, checked against a `070702` CRC. */
|
||||
async *#verified(
|
||||
source: ByteSource,
|
||||
header: CpioHeader,
|
||||
path: string,
|
||||
record?: (observation: ArtifactChecksumObservation) => void,
|
||||
policy?: "fail" | "record-and-continue",
|
||||
): AsyncGenerator<Buffer> {
|
||||
const sum = { value: 0 };
|
||||
yield* this.#payload(source, header, sum);
|
||||
const mismatch = this.#crcMismatch(
|
||||
header,
|
||||
path,
|
||||
sum.value,
|
||||
policy ?? this.integrity,
|
||||
);
|
||||
if (mismatch !== undefined) record?.(mismatch);
|
||||
}
|
||||
|
||||
async #collect(
|
||||
source: ByteSource,
|
||||
header: CpioHeader,
|
||||
path: string,
|
||||
): Promise<Buffer> {
|
||||
// Unopened linked bytes are not a recovered occurrence. A disagreement
|
||||
// still fails the archive instead of being remembered as verified content.
|
||||
const consumed = await this.#consume(source, header, path, true, "fail");
|
||||
return consumed.bytes ?? Buffer.alloc(0);
|
||||
}
|
||||
|
||||
#cancelled(): void {
|
||||
if (this.#signal?.aborted)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"cpio expansion was cancelled",
|
||||
);
|
||||
}
|
||||
|
||||
open(entry: ArtifactEntry): Promise<Readable> {
|
||||
const alias = this.#aliases.get(entry.adapterKey);
|
||||
if (alias !== undefined) {
|
||||
this.decodedBudget.consume(alias.length, entry.path);
|
||||
return Promise.resolve(Readable.from([alias]));
|
||||
}
|
||||
const source = this.#source;
|
||||
const current = this.#current;
|
||||
if (
|
||||
source === undefined ||
|
||||
current === undefined ||
|
||||
current.key !== entry.adapterKey ||
|
||||
this.#consumed
|
||||
)
|
||||
return Promise.reject(
|
||||
new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
entry.contentUnavailable === true
|
||||
? UNRESOLVED_LINK
|
||||
: "cpio members can be read only once, in archive order",
|
||||
),
|
||||
);
|
||||
this.#consumed = true;
|
||||
const chunks = this.#verified(
|
||||
source,
|
||||
current.header,
|
||||
current.path,
|
||||
this.integrity === "record-and-continue"
|
||||
? (observed) => this.#integrity.set(entry.adapterKey, [observed])
|
||||
: undefined,
|
||||
);
|
||||
if (!this.#shouldKeep(current.header))
|
||||
return Promise.resolve(Readable.from(chunks));
|
||||
// Keep a linked member's bytes for paths that share them.
|
||||
const kept: Buffer[] = [];
|
||||
const remember = (): void => {
|
||||
this.#remember(
|
||||
current.header,
|
||||
Buffer.concat(kept),
|
||||
this.#integrity.get(entry.adapterKey),
|
||||
);
|
||||
};
|
||||
return Promise.resolve(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
for await (const chunk of chunks) {
|
||||
kept.push(chunk);
|
||||
yield chunk;
|
||||
}
|
||||
remember();
|
||||
})(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
provenance(): readonly ArtifactCommand[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Completed member CRC evidence, including inherited hard-link source contradictions. */
|
||||
integrityObservations(
|
||||
entry: ArtifactEntry,
|
||||
): readonly ArtifactChecksumObservation[] {
|
||||
return this.#integrity.get(entry.adapterKey) ?? [];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.#source?.close();
|
||||
this.#source = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/** xar archives and cpio payloads built field by field for installer-package tests. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { deflateSync, gzipSync, inflateSync } from "node:zlib";
|
||||
|
||||
export interface XarFixtureMember {
|
||||
readonly name: string;
|
||||
readonly type?: "file" | "directory" | "symlink";
|
||||
readonly data?: Uint8Array;
|
||||
readonly encoding?: "raw" | "zlib" | "bzip2";
|
||||
readonly link?: string;
|
||||
readonly mode?: string;
|
||||
/** Replace the declared extracted checksum, to model tampering. */
|
||||
readonly extractedSha1?: string;
|
||||
/** Replace the declared stored checksum while retaining decodable content. */
|
||||
readonly archivedSha1?: string;
|
||||
/** Store these bytes instead of encoding `data`, to model corrupt members. */
|
||||
readonly archived?: Uint8Array;
|
||||
readonly children?: readonly XarFixtureMember[];
|
||||
}
|
||||
|
||||
const sha1 = (bytes: Uint8Array): string =>
|
||||
createHash("sha1").update(bytes).digest("hex");
|
||||
|
||||
const escape = (text: string): string =>
|
||||
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
|
||||
/** Build a xar archive with a SHA-1 TOC checksum and per-member checksums. */
|
||||
export const xarArchive = (
|
||||
members: readonly XarFixtureMember[],
|
||||
options: {
|
||||
readonly corruptTocChecksum?: boolean;
|
||||
/** Declared TOC checksum size; the real digest is 20 bytes. */
|
||||
readonly tocChecksumSize?: number;
|
||||
/** Override the fixed-header decoded length without altering the TOC. */
|
||||
readonly tocDecodedSize?: number;
|
||||
} = {},
|
||||
): Uint8Array => {
|
||||
const heap: Uint8Array[] = [new Uint8Array(20)];
|
||||
let heapLength = 20;
|
||||
let nextId = 1;
|
||||
const fileXml = (member: XarFixtureMember): string => {
|
||||
const id = nextId++;
|
||||
const type = member.type ?? "file";
|
||||
let data = "";
|
||||
if (type === "file" && member.data !== undefined) {
|
||||
const archived =
|
||||
member.archived ??
|
||||
(member.encoding === "zlib" ? deflateSync(member.data) : member.data);
|
||||
const offset = heapLength;
|
||||
heap.push(archived);
|
||||
heapLength += archived.length;
|
||||
const style =
|
||||
member.encoding === "zlib"
|
||||
? "application/x-gzip"
|
||||
: member.encoding === "bzip2"
|
||||
? "application/x-bzip2"
|
||||
: "application/octet-stream";
|
||||
data = `<data><archived-checksum style="sha1">${member.archivedSha1 ?? sha1(archived)}</archived-checksum><extracted-checksum style="sha1">${member.extractedSha1 ?? sha1(member.data)}</extracted-checksum><encoding style="${style}"/><size>${member.data.length}</size><offset>${offset}</offset><length>${archived.length}</length></data>`;
|
||||
}
|
||||
const link =
|
||||
type === "symlink"
|
||||
? `<link type="file">${escape(member.link ?? "")}</link>`
|
||||
: "";
|
||||
const children = (member.children ?? []).map(fileXml).join("");
|
||||
return `<file id="${id}"><name>${escape(member.name)}</name><type>${type}</type><mode>${member.mode ?? (type === "directory" ? "0755" : "0644")}</mode>${data}${link}${children}</file>`;
|
||||
};
|
||||
const files = members.map(fileXml).join("");
|
||||
const toc = Buffer.from(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><xar><toc><checksum style="sha1"><size>${options.tocChecksumSize ?? 20}</size><offset>0</offset></checksum>${files}</toc></xar>`,
|
||||
);
|
||||
const compressed = deflateSync(toc);
|
||||
const checksum = createHash("sha1").update(compressed).digest();
|
||||
if (options.corruptTocChecksum === true)
|
||||
checksum[0] = (checksum[0] ?? 0) ^ 0xff;
|
||||
heap[0] = checksum;
|
||||
const header = Buffer.alloc(28);
|
||||
header.writeUInt32BE(0x78617221, 0);
|
||||
header.writeUInt16BE(28, 4);
|
||||
header.writeUInt16BE(1, 6);
|
||||
header.writeBigUInt64BE(BigInt(compressed.length), 8);
|
||||
header.writeBigUInt64BE(BigInt(options.tocDecodedSize ?? toc.length), 16);
|
||||
header.writeUInt32BE(1, 24);
|
||||
return Buffer.concat([header, compressed, ...heap]);
|
||||
};
|
||||
|
||||
export interface CpioFixtureMember {
|
||||
readonly name: string;
|
||||
readonly mode: number;
|
||||
readonly data?: Uint8Array | string;
|
||||
readonly ino?: number;
|
||||
readonly links?: number;
|
||||
/** Override a crc archive's c_check, to model corruption. */
|
||||
readonly check?: number;
|
||||
}
|
||||
|
||||
const octal = (value: number, width: number): string =>
|
||||
value.toString(8).padStart(width, "0");
|
||||
const hex = (value: number): string => value.toString(16).padStart(8, "0");
|
||||
|
||||
/** Build a gzip-compressed odc or newc cpio archive ending in its trailer. */
|
||||
export const gzipCpio = (
|
||||
members: readonly CpioFixtureMember[],
|
||||
format: "odc" | "newc" | "crc" = "odc",
|
||||
): Uint8Array => {
|
||||
const parts: Buffer[] = [];
|
||||
const pad = (length: number): void => {
|
||||
if (format !== "odc" && length % 4 !== 0)
|
||||
parts.push(Buffer.alloc(4 - (length % 4)));
|
||||
};
|
||||
for (const member of [...members, { name: "TRAILER!!!", mode: 0 }]) {
|
||||
const data =
|
||||
typeof member.data === "string"
|
||||
? Buffer.from(member.data)
|
||||
: Buffer.from(member.data ?? new Uint8Array());
|
||||
const name = Buffer.from(`${member.name}\0`);
|
||||
const ino = "ino" in member ? (member.ino ?? 1) : 1;
|
||||
const links = "links" in member ? (member.links ?? 1) : 1;
|
||||
const sum = [...data].reduce((total, byte) => (total + byte) >>> 0, 0);
|
||||
const check = "check" in member ? (member.check ?? sum) : sum;
|
||||
if (format === "odc")
|
||||
parts.push(
|
||||
Buffer.from(
|
||||
`070707${octal(0, 6)}${octal(ino, 6)}${octal(member.mode, 6)}${octal(0, 6)}${octal(0, 6)}${octal(links, 6)}${octal(0, 6)}${octal(0, 11)}${octal(name.length, 6)}${octal(data.length, 11)}`,
|
||||
),
|
||||
name,
|
||||
data,
|
||||
);
|
||||
else {
|
||||
parts.push(
|
||||
Buffer.from(
|
||||
`${format === "crc" ? "070702" : "070701"}${hex(ino)}${hex(member.mode)}${hex(0)}${hex(0)}${hex(links)}${hex(0)}${hex(data.length)}${hex(0)}${hex(0)}${hex(0)}${hex(0)}${hex(name.length)}${hex(format === "crc" ? check : 0)}`,
|
||||
),
|
||||
name,
|
||||
);
|
||||
pad(110 + name.length);
|
||||
parts.push(data);
|
||||
pad(data.length);
|
||||
}
|
||||
}
|
||||
return gzipSync(Buffer.concat(parts));
|
||||
};
|
||||
|
||||
export const MODE = {
|
||||
directory: 0o040755,
|
||||
file: 0o100644,
|
||||
executable: 0o100755,
|
||||
symlink: 0o120755,
|
||||
fifo: 0o010644,
|
||||
} as const;
|
||||
|
||||
/** Mutate a fixture's XML while keeping its compressed TOC checksum/lengths consistent. */
|
||||
export const rewriteXarToc = (
|
||||
archive: Uint8Array,
|
||||
rewrite: (xml: string) => string,
|
||||
): Uint8Array => {
|
||||
const input = Buffer.from(archive);
|
||||
const oldLength = Number(input.readBigUInt64BE(8));
|
||||
const decoded = inflateSync(input.subarray(28, 28 + oldLength));
|
||||
const xml = Buffer.from(rewrite(decoded.toString("utf8")));
|
||||
const compressed = deflateSync(xml);
|
||||
const header = Buffer.from(input.subarray(0, 28));
|
||||
header.writeBigUInt64BE(BigInt(compressed.length), 8);
|
||||
header.writeBigUInt64BE(BigInt(xml.length), 16);
|
||||
const heap = Buffer.from(input.subarray(28 + oldLength));
|
||||
createHash("sha1").update(compressed).digest().copy(heap, 0);
|
||||
return Buffer.concat([header, compressed, heap]);
|
||||
};
|
||||
@@ -1,604 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { Readable } from "node:stream";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { ArtifactDecodedBudget } from "./ArtifactDecodedBudget.js";
|
||||
import { CpioArtifactReader } from "./CpioArtifactReader.js";
|
||||
import {
|
||||
MODE,
|
||||
gzipCpio,
|
||||
xarArchive,
|
||||
rewriteXarToc,
|
||||
type XarFixtureMember,
|
||||
} from "./InstallerPackage.fixture.js";
|
||||
import { XarArtifactReader } from "./XarArtifactReader.js";
|
||||
import { scanReader } from "./inventory/scanReader.js";
|
||||
import { visitArtifactTree } from "./ArtifactTraversal.js";
|
||||
|
||||
const withReader = async (
|
||||
bytes: Uint8Array,
|
||||
run: (reader: XarArtifactReader) => Promise<void>,
|
||||
decodedBudget?: ArtifactDecodedBudget,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-pkg-limits-");
|
||||
const path = join(root, "fixture.pkg");
|
||||
await writeFile(path, bytes);
|
||||
const reader = new XarArtifactReader(
|
||||
path,
|
||||
decodedBudget === undefined ? {} : { decodedBudget },
|
||||
);
|
||||
try {
|
||||
await run(reader);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
};
|
||||
|
||||
it("shares an inflation budget across separate zlib members", async () => {
|
||||
const bytes = xarArchive([
|
||||
{ name: "a", data: Buffer.alloc(40, 65), encoding: "zlib" },
|
||||
{ name: "b", data: Buffer.alloc(40, 66), encoding: "zlib" },
|
||||
]);
|
||||
await withReader(
|
||||
bytes,
|
||||
async (reader) => {
|
||||
let read = 0;
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) {
|
||||
await buffer(await reader.open(entry));
|
||||
read++;
|
||||
}
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("Decoded archive budget"),
|
||||
});
|
||||
expect(read).toBe(1);
|
||||
},
|
||||
new ArtifactDecodedBudget(64),
|
||||
);
|
||||
});
|
||||
|
||||
it("charges raw member reads to the same cumulative work budget", async () => {
|
||||
await withReader(
|
||||
xarArchive([
|
||||
{ name: "a", data: Buffer.alloc(40, 65) },
|
||||
{ name: "b", data: Buffer.alloc(40, 66) },
|
||||
]),
|
||||
async (reader) => {
|
||||
let read = 0;
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) {
|
||||
await buffer(await reader.open(entry));
|
||||
read++;
|
||||
}
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("Decoded archive budget"),
|
||||
});
|
||||
expect(read).toBe(1);
|
||||
},
|
||||
new ArtifactDecodedBudget(64),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates unsafe extents even when a member is unsupported and would not be opened", async () => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", encoding: "bzip2", data: Buffer.from("opaque") }]),
|
||||
(xml) =>
|
||||
xml.replace(
|
||||
/<offset>20<\/offset>/u,
|
||||
`<offset>${Number.MAX_SAFE_INTEGER}</offset>`,
|
||||
),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("heap extent"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", " ", "1e2", "0x10"])(
|
||||
"rejects nondecimal/empty numeric TOC data: %j",
|
||||
async (value) => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", data: Buffer.from("bytes") }]),
|
||||
(xml) =>
|
||||
xml.replace(/<length>\d+<\/length>/u, `<length>${value}</length>`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "xar TOC has an invalid data length",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe absolute heap ranges with a tagged format failure", async () => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "Payload", data: Buffer.from("gzip") }]),
|
||||
(xml) =>
|
||||
xml.replace(
|
||||
/<offset>20<\/offset>/u,
|
||||
`<offset>${Number.MAX_SAFE_INTEGER}</offset>`,
|
||||
),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("heap extent"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds retained full paths from deeply nested TOCs", async () => {
|
||||
let member: XarFixtureMember = { name: "leaf", data: Buffer.from("x") };
|
||||
for (let depth = 0; depth < 300; depth++)
|
||||
member = {
|
||||
name: "directory-segment",
|
||||
type: "directory",
|
||||
children: [member],
|
||||
};
|
||||
await withReader(xarArchive([member]), async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("path text"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("observes cancellation during internal symlink drains", async () => {
|
||||
const controller = new AbortController();
|
||||
const archive = gzipCpio([
|
||||
{
|
||||
name: "huge-link",
|
||||
mode: MODE.symlink,
|
||||
data: "x".repeat(4 * 1024 * 1024),
|
||||
},
|
||||
]);
|
||||
let decoded = 0;
|
||||
const budget = new (class extends ArtifactDecodedBudget {
|
||||
override consume(bytes: number, path: string): void {
|
||||
super.consume(bytes, path);
|
||||
decoded += bytes;
|
||||
if (decoded >= 64 * 1024) controller.abort();
|
||||
}
|
||||
})();
|
||||
const reader = new CpioArtifactReader(
|
||||
async () => Readable.from([archive]),
|
||||
"fail",
|
||||
budget,
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries(controller.signal)) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies gzip integrity even when the cpio trailer has already been decoded", async () => {
|
||||
const bytes = Buffer.from(
|
||||
gzipCpio([{ name: "a", mode: MODE.file, data: "ok" }]),
|
||||
);
|
||||
bytes[bytes.length - 8] = (bytes[bytes.length - 8] ?? 0) ^ 1;
|
||||
const reader = new CpioArtifactReader(async () => Readable.from([bytes]));
|
||||
try {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({ reason: "format" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["0755junk", "-1", "", "08"])(
|
||||
"rejects partially parsed or malformed mode %j",
|
||||
async (mode) => {
|
||||
await withReader(
|
||||
xarArchive([{ name: "a", mode, data: Buffer.from("x") }]),
|
||||
async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "xar TOC has an invalid mode",
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves CRC contradictions for special nodes and reaches later siblings", async () => {
|
||||
const bytes = gzipCpio(
|
||||
[
|
||||
{ name: "fifo", mode: MODE.fifo, data: "abc", check: 1 },
|
||||
{ name: "good", mode: MODE.file, data: "ok" },
|
||||
],
|
||||
"crc",
|
||||
);
|
||||
const reader = new CpioArtifactReader(
|
||||
async () => Readable.from([bytes]),
|
||||
"record-and-continue",
|
||||
);
|
||||
try {
|
||||
const paths: string[] = [];
|
||||
for await (const entry of reader.entries()) {
|
||||
paths.push(entry.path);
|
||||
if (entry.path === "fifo")
|
||||
expect(entry.limitations).toContain(
|
||||
"Declared decoded cpio-byte-sum 00000001 disagrees with observed 00000126.",
|
||||
);
|
||||
if (entry.path === "good")
|
||||
expect((await buffer(await reader.open(entry))).toString()).toBe("ok");
|
||||
}
|
||||
expect(paths).toEqual(["fifo", "good"]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not expose a partial inventory when a previous TOC load failed", async () => {
|
||||
await withReader(
|
||||
xarArchive([{ name: "a", mode: "broken", data: Buffer.from("x") }]),
|
||||
async (reader) => {
|
||||
const scan = async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
};
|
||||
await expect(scan()).rejects.toMatchObject({ reason: "format" });
|
||||
await expect(scan()).rejects.toMatchObject({ reason: "format" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a TOC whose element count would build an unbounded DOM", async () => {
|
||||
const noise = "<n/>".repeat(100_001);
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", data: Buffer.from("ok") }]),
|
||||
(xml) => xml.replace("<toc>", `<toc>${noise}`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: "xar TOC exceeds 100000 XML nodes",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a TOC checksum when the fixed header declares none", async () => {
|
||||
const bytes = Buffer.from(
|
||||
xarArchive([{ name: "a", data: Buffer.from("ok") }]),
|
||||
);
|
||||
bytes.writeUInt32BE(0, 24);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("declares no TOC checksum"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["", "nothex", "a".repeat(63)])(
|
||||
"rejects malformed checksum text %j before applying integrity policy",
|
||||
async (digest) => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", data: Buffer.from("ok") }]),
|
||||
(xml) =>
|
||||
xml.replace(
|
||||
/<extracted-checksum style="sha1">[^<]*<\/extracted-checksum>/u,
|
||||
`<extracted-checksum style="sha256">${digest}</extracted-checksum>`,
|
||||
),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "xar extracted-checksum has malformed sha256 digest text",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a CRC trailer whose declared checksum is not empty-data zero", async () => {
|
||||
const raw = gunzipSync(gzipCpio([], "crc"));
|
||||
raw.write("00000001", 102, "ascii");
|
||||
const reader = new CpioArtifactReader(async () =>
|
||||
Readable.from([gzipSync(raw)]),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
message:
|
||||
"cpio CRC disagrees with content: TRAILER!!! (Declared decoded cpio-byte-sum 00000001 disagrees with observed 00000000.)",
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds retained metadata independently of small decoded byte counts", async () => {
|
||||
const members = Array.from({ length: 100 }, (_, index) => ({
|
||||
name: `empty-${index}`,
|
||||
mode: MODE.file,
|
||||
}));
|
||||
const reader = new CpioArtifactReader(
|
||||
async () => Readable.from([gzipCpio(members)]),
|
||||
"fail",
|
||||
new ArtifactDecodedBudget(1024 * 1024, 32 * 1024),
|
||||
);
|
||||
let seen = 0;
|
||||
try {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) {
|
||||
void entry;
|
||||
seen++;
|
||||
}
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("Retained archive metadata budget"),
|
||||
});
|
||||
expect(seen).toBeGreaterThan(0);
|
||||
expect(seen).toBeLessThan(members.length);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers directory CRC contradictions before yielding and reaches regular siblings", async () => {
|
||||
const reader = new CpioArtifactReader(
|
||||
async () =>
|
||||
Readable.from([
|
||||
gzipCpio(
|
||||
[
|
||||
{ name: "dir", mode: MODE.directory, check: 1 },
|
||||
{ name: "dir/good", mode: MODE.file, data: "ok" },
|
||||
],
|
||||
"crc",
|
||||
),
|
||||
]),
|
||||
"record-and-continue",
|
||||
);
|
||||
try {
|
||||
const seen: string[] = [];
|
||||
for await (const entry of reader.entries()) {
|
||||
seen.push(entry.path);
|
||||
if (entry.kind === "directory")
|
||||
expect(entry.limitations).toContain(
|
||||
"Declared decoded cpio-byte-sum 00000001 disagrees with observed 00000000.",
|
||||
);
|
||||
else
|
||||
expect((await buffer(await reader.open(entry))).toString()).toBe("ok");
|
||||
}
|
||||
expect(seen).toEqual(["dir", "dir/good"]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["directory", "symlink"])(
|
||||
"rejects data declarations attached to non-file type %s",
|
||||
async (type) => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", data: Buffer.from("data") }]),
|
||||
(xml) => xml.replace("<type>file</type>", `<type>${type}</type>`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries()) void entry;
|
||||
})(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "xar non-file member declares data: a",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("charges long enclosing prefixes for every retained nested entry", async () => {
|
||||
const files = Array.from({ length: 100 }, (_, index) => ({
|
||||
name: `empty-${index}`,
|
||||
mode: MODE.file,
|
||||
}));
|
||||
let member: XarFixtureMember = { name: "Payload", data: gzipCpio(files) };
|
||||
for (let index = 0; index < 18; index++)
|
||||
member = { name: "d".repeat(200), type: "directory", children: [member] };
|
||||
await withReader(
|
||||
xarArchive([member]),
|
||||
async (reader) => {
|
||||
let retained = 0;
|
||||
await expect(
|
||||
visitArtifactTree(reader, async ({ container }) => {
|
||||
if (!container) retained++;
|
||||
return container;
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("Retained archive metadata budget"),
|
||||
});
|
||||
expect(retained).toBeLessThan(files.length + 18);
|
||||
},
|
||||
new ArtifactDecodedBudget(1024 * 1024, 512 * 1024),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["<!--n-->", "<?n value?>", "<![CDATA[n]]>"])(
|
||||
"bounds DOM nodes produced by %s before parsing",
|
||||
async (node) => {
|
||||
const bytes = rewriteXarToc(xarArchive([]), (xml) =>
|
||||
xml.replace("<toc>", `<toc>${node.repeat(100_001)}`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(scanReader(reader)).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("XML nodes"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["data", "extracted-checksum", "offset", "encoding", "type"])(
|
||||
"rejects duplicate singleton <%s> declarations",
|
||||
async (tag) => {
|
||||
const bytes = rewriteXarToc(
|
||||
xarArchive([{ name: "a", data: Buffer.from("ok") }]),
|
||||
(xml) => {
|
||||
const pattern = new RegExp(
|
||||
`<${tag}(?:\\s[^>]*|)>(?:[^]*?)</${tag}>|<${tag}[^>]*/>`,
|
||||
);
|
||||
return xml.replace(pattern, (element) => element + element);
|
||||
},
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(scanReader(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining(`duplicate <${tag}>`),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsupported xar versions before reading the TOC", async () => {
|
||||
const bytes = Buffer.from(xarArchive([]));
|
||||
bytes.writeUInt16BE(2, 6);
|
||||
await withReader(bytes, async (reader) => {
|
||||
await expect(scanReader(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("version 2"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("ends record-and-continue traversal when its shared decoded budget is exhausted", async () => {
|
||||
const bytes = xarArchive(
|
||||
Array.from({ length: 10 }, (_, index) => ({
|
||||
name: `file-${index}`,
|
||||
data: Buffer.alloc(40, index),
|
||||
})),
|
||||
);
|
||||
await withReader(
|
||||
bytes,
|
||||
async (reader) => {
|
||||
const opened: string[] = [];
|
||||
const observed = {
|
||||
format: reader.format,
|
||||
provenance: reader.provenance.bind(reader),
|
||||
entries: reader.entries.bind(reader),
|
||||
close: reader.close.bind(reader),
|
||||
decodedBudget: reader.decodedBudget,
|
||||
open: async (...args: Parameters<typeof reader.open>) => {
|
||||
opened.push(args[0].path);
|
||||
return reader.open(...args);
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
scanReader(observed, undefined, { mode: "record-and-continue" }),
|
||||
).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: expect.stringContaining("Decoded archive budget"),
|
||||
});
|
||||
expect(opened).toEqual(["file-0", "file-1"]);
|
||||
},
|
||||
new ArtifactDecodedBudget(64),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts productbuild repeated identical names but rejects contradictory names", async () => {
|
||||
const original = xarArchive([{ name: "a", data: Buffer.from("ok") }]);
|
||||
for (const name of ["a", "different"]) {
|
||||
const bytes = rewriteXarToc(original, (xml) =>
|
||||
xml.replace("<name>a</name>", `<name>a</name><name>${name}</name>`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
if (name === "a")
|
||||
expect((await scanReader(reader)).occurrences).toHaveLength(1);
|
||||
else
|
||||
await expect(scanReader(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("conflicting <name>"),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a comment as one node even when its text contains markup openers", async () => {
|
||||
const bytes = rewriteXarToc(xarArchive([]), (xml) =>
|
||||
xml.replace("<toc>", `<toc><!--${"<".repeat(100_001)}-->`),
|
||||
);
|
||||
await withReader(bytes, async (reader) => {
|
||||
expect((await scanReader(reader)).occurrences).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
it("resolves a large complete empty hard-link group without per-alias rescans", async () => {
|
||||
const count = 20_000;
|
||||
const bytes = gzipCpio(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: `link-${index}`,
|
||||
mode: MODE.file,
|
||||
data: "",
|
||||
ino: 1,
|
||||
links: count,
|
||||
})),
|
||||
"newc",
|
||||
);
|
||||
const reader = new CpioArtifactReader(async () => Readable.from([bytes]));
|
||||
try {
|
||||
let retained = 0;
|
||||
for await (const entry of reader.entries()) {
|
||||
expect(entry.declaredSize).toBe(0);
|
||||
expect(entry.contentUnavailable).not.toBe(true);
|
||||
retained++;
|
||||
}
|
||||
expect(retained).toBe(count);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
@@ -1,444 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ArtifactEntry } from "./ArtifactReader.js";
|
||||
import { CpioArtifactReader } from "./CpioArtifactReader.js";
|
||||
import { MODE, gzipCpio, xarArchive } from "./InstallerPackage.fixture.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { XarArtifactReader } from "./XarArtifactReader.js";
|
||||
|
||||
const writePackage = async (bytes: Uint8Array): Promise<string> => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-reader-");
|
||||
const path = join(directory, "fixture.pkg");
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
};
|
||||
|
||||
const collect = async (reader: {
|
||||
entries(): AsyncIterable<ArtifactEntry>;
|
||||
}): Promise<ArtifactEntry[]> => {
|
||||
const entries: ArtifactEntry[] = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
return entries;
|
||||
};
|
||||
|
||||
describe("xar installer package reader", () => {
|
||||
it("lists nested members and marks gzip-cpio scripts and payloads", async () => {
|
||||
const scripts = gzipCpio([
|
||||
{ name: "./postinstall", mode: MODE.executable, data: "#!/bin/sh\n" },
|
||||
]);
|
||||
const reader = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([
|
||||
{
|
||||
name: "Distribution",
|
||||
data: Buffer.from("<installer-gui-script/>"),
|
||||
encoding: "zlib",
|
||||
},
|
||||
{
|
||||
name: "App.pkg",
|
||||
type: "directory",
|
||||
children: [
|
||||
{
|
||||
name: "PackageInfo",
|
||||
data: Buffer.from("<pkg-info/>"),
|
||||
encoding: "zlib",
|
||||
},
|
||||
{ name: "Scripts", data: scripts },
|
||||
{ name: "Payload", data: Buffer.from("pbzx\0\0\0\0\x01\0\0\0") },
|
||||
{ name: "Current", type: "symlink", link: "App.pkg" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const entries = await collect(reader);
|
||||
expect(entries.map(({ path, kind }) => `${kind}:${path}`)).toEqual([
|
||||
"file:Distribution",
|
||||
"directory:App.pkg",
|
||||
"file:App.pkg/PackageInfo",
|
||||
"file:App.pkg/Scripts",
|
||||
"file:App.pkg/Payload",
|
||||
"symlink:App.pkg/Current",
|
||||
]);
|
||||
const scriptsEntry = entries.find(
|
||||
({ path }) => path === "App.pkg/Scripts",
|
||||
);
|
||||
expect(scriptsEntry?.nestedArchive).toBe("gzip-cpio");
|
||||
expect(
|
||||
entries.find(({ path }) => path === "App.pkg/Payload")?.limitations,
|
||||
).toEqual([expect.stringContaining("pbzx")]);
|
||||
const distribution = entries[0];
|
||||
if (distribution === undefined) throw new Error("missing entry");
|
||||
expect((await buffer(await reader.open(distribution))).toString()).toBe(
|
||||
"<installer-gui-script/>",
|
||||
);
|
||||
if (scriptsEntry === undefined) throw new Error("missing scripts");
|
||||
const nested = new CpioArtifactReader(() => reader.open(scriptsEntry));
|
||||
try {
|
||||
const members: string[] = [];
|
||||
for await (const entry of nested.entries()) {
|
||||
members.push(entry.path);
|
||||
expect((await buffer(await nested.open(entry))).toString()).toBe(
|
||||
"#!/bin/sh\n",
|
||||
);
|
||||
expect(entry.executable).toBe(true);
|
||||
}
|
||||
expect(members).toEqual(["postinstall"]);
|
||||
} finally {
|
||||
await nested.close();
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects tampered members, TOCs and unsupported encodings", async () => {
|
||||
const tampered = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([
|
||||
{
|
||||
name: "Bom",
|
||||
data: Buffer.from("bom"),
|
||||
extractedSha1: "0".repeat(40),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const [entry] = await collect(tampered);
|
||||
if (entry === undefined) throw new Error("missing entry");
|
||||
await expect(buffer(await tampered.open(entry))).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
} finally {
|
||||
await tampered.close();
|
||||
}
|
||||
const corrupt = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([{ name: "Bom", data: Buffer.from("bom") }], {
|
||||
corruptTocChecksum: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(collect(corrupt)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
await corrupt.close();
|
||||
const bzip = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([
|
||||
{ name: "Bom", data: Buffer.from("bom"), encoding: "bzip2" },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [entry] = await collect(bzip);
|
||||
if (entry === undefined) throw new Error("missing entry");
|
||||
expect(entry.contentUnavailable).toBe(true);
|
||||
await expect(bzip.open(entry)).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
});
|
||||
await bzip.close();
|
||||
const notXar = new XarArtifactReader(
|
||||
await writePackage(Buffer.from("not an archive at all")),
|
||||
);
|
||||
await expect(collect(notXar)).rejects.toMatchObject({ reason: "format" });
|
||||
await notXar.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gzip cpio reader", () => {
|
||||
for (const format of ["odc", "newc"] as const)
|
||||
it(`reads ${format} members in order and skips unopened data`, async () => {
|
||||
const archive = gzipCpio(
|
||||
[
|
||||
{ name: ".", mode: MODE.directory },
|
||||
{ name: "./usr", mode: MODE.directory },
|
||||
{
|
||||
name: "./usr/bin/tool",
|
||||
mode: MODE.executable,
|
||||
data: "skipped bytes",
|
||||
},
|
||||
{ name: "./usr/lib/link", mode: MODE.symlink, data: "../bin/tool" },
|
||||
{ name: "./usr/fifo", mode: MODE.fifo },
|
||||
{ name: "./usr/share/readme.txt", mode: MODE.file, data: "read me" },
|
||||
],
|
||||
format,
|
||||
);
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(Readable.from([archive])),
|
||||
);
|
||||
const seen: string[] = [];
|
||||
try {
|
||||
for await (const entry of reader.entries()) {
|
||||
seen.push(`${entry.kind}:${entry.path}`);
|
||||
if (entry.path === "usr/share/readme.txt") {
|
||||
expect((await buffer(await reader.open(entry))).toString()).toBe(
|
||||
"read me",
|
||||
);
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
});
|
||||
}
|
||||
if (entry.kind === "symlink")
|
||||
expect(entry.limitations).toEqual(["Symlink target: ../bin/tool"]);
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
"directory:usr",
|
||||
"file:usr/bin/tool",
|
||||
"symlink:usr/lib/link",
|
||||
"file:usr/fifo",
|
||||
"file:usr/share/readme.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects traversal and truncated archives", async () => {
|
||||
for (const name of ["../escape", "/absolute"]) {
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(
|
||||
Readable.from([gzipCpio([{ name, mode: MODE.file, data: "x" }])]),
|
||||
),
|
||||
);
|
||||
await expect(collect(reader)).rejects.toMatchObject({ reason: "path" });
|
||||
await reader.close();
|
||||
}
|
||||
const whole = gzipCpio([{ name: "a", mode: MODE.file, data: "abc" }]);
|
||||
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||
const truncated = gzipSync(gunzipSync(whole).subarray(0, 60));
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(Readable.from([truncated])),
|
||||
);
|
||||
await expect(collect(reader)).rejects.toMatchObject({ reason: "format" });
|
||||
await reader.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installer package reader hardening", () => {
|
||||
it("refuses a TOC checksum whose declared size is not one digest", async () => {
|
||||
const reader = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([{ name: "Bom", data: Buffer.from("bom") }], {
|
||||
tocChecksumSize: 0x7fffffff,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(collect(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "xar TOC declares a 2147483647-byte sha1 checksum",
|
||||
});
|
||||
await reader.close();
|
||||
});
|
||||
|
||||
it("reports corrupt zlib members as malformed and keeps cancellation tagged", async () => {
|
||||
const reader = new XarArtifactReader(
|
||||
await writePackage(
|
||||
xarArchive([
|
||||
{
|
||||
name: "PackageInfo",
|
||||
data: Buffer.from("<pkg-info/>"),
|
||||
encoding: "zlib",
|
||||
archived: Buffer.from("not zlib at all"),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const [entry] = await collect(reader);
|
||||
if (entry === undefined) throw new Error("missing entry");
|
||||
await expect(buffer(await reader.open(entry))).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(reader.open(entry, controller.signal)).rejects.toMatchObject(
|
||||
{
|
||||
reason: "cancelled",
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cpio symlink targets", () => {
|
||||
it("streams past an oversized symlink target and keeps reading", async () => {
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(
|
||||
Readable.from([
|
||||
gzipCpio(
|
||||
[
|
||||
{ name: "./huge", mode: MODE.symlink, data: "x".repeat(4097) },
|
||||
{ name: "./after", mode: MODE.file, data: "after" },
|
||||
],
|
||||
"crc",
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const entries = await collect(reader);
|
||||
expect(
|
||||
entries.map(({ kind, path, limitations }) => [kind, path, limitations]),
|
||||
).toEqual([
|
||||
[
|
||||
"symlink",
|
||||
"huge",
|
||||
["Symlink target of 4097 bytes exceeds 4096 bytes and was not read."],
|
||||
],
|
||||
["file", "after", []],
|
||||
]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cpio hard links and CRC archives", () => {
|
||||
const readAll = async (archive: Uint8Array) => {
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(Readable.from([archive])),
|
||||
);
|
||||
const files: Record<string, string | undefined> = {};
|
||||
try {
|
||||
for await (const entry of reader.entries())
|
||||
if (entry.kind === "file")
|
||||
files[entry.path] =
|
||||
entry.contentUnavailable === true
|
||||
? undefined
|
||||
: (await buffer(await reader.open(entry))).toString();
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
it("serves newc hard links stored on the last member to every path", async () => {
|
||||
expect(
|
||||
await readAll(
|
||||
gzipCpio(
|
||||
[
|
||||
{ name: "./first", mode: MODE.file, ino: 7, links: 2 },
|
||||
{
|
||||
name: "./second",
|
||||
mode: MODE.file,
|
||||
ino: 7,
|
||||
links: 2,
|
||||
data: "shared",
|
||||
},
|
||||
{
|
||||
name: "./third",
|
||||
mode: MODE.file,
|
||||
ino: 9,
|
||||
links: 2,
|
||||
data: "kept",
|
||||
},
|
||||
{ name: "./fourth", mode: MODE.file, ino: 9, links: 2 },
|
||||
],
|
||||
"newc",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
first: "shared",
|
||||
second: "shared",
|
||||
third: "kept",
|
||||
fourth: "kept",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a hard link whose bytes never appear as unavailable", async () => {
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(
|
||||
Readable.from([
|
||||
gzipCpio(
|
||||
[{ name: "./orphan", mode: MODE.file, ino: 4, links: 2 }],
|
||||
"newc",
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const entries = await collect(reader);
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "orphan",
|
||||
contentUnavailable: true,
|
||||
declaredSize: null,
|
||||
}),
|
||||
]);
|
||||
const orphan = entries[0];
|
||||
if (orphan === undefined) throw new Error("missing entry");
|
||||
await expect(reader.open(orphan)).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
});
|
||||
await reader.close();
|
||||
});
|
||||
|
||||
it("verifies 070702 member checksums whether or not members are opened", async () => {
|
||||
expect(
|
||||
await readAll(
|
||||
gzipCpio([{ name: "./ok", mode: MODE.file, data: "abc" }], "crc"),
|
||||
),
|
||||
).toEqual({ ok: "abc" });
|
||||
await expect(
|
||||
readAll(
|
||||
gzipCpio(
|
||||
[{ name: "./bad", mode: MODE.file, data: "abc", check: 1 }],
|
||||
"crc",
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
const reader = new CpioArtifactReader(() =>
|
||||
Promise.resolve(
|
||||
Readable.from([
|
||||
gzipCpio(
|
||||
[{ name: "./bad", mode: MODE.file, data: "abc", check: 1 }],
|
||||
"crc",
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
await expect(collect(reader)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
await reader.close();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["newc", "crc"] as const)(
|
||||
"rejects nonzero %s alignment under either integrity policy",
|
||||
async (format) => {
|
||||
for (const policy of ["fail", "record-and-continue"] as const) {
|
||||
for (const [offset, label] of [
|
||||
[113, "name padding"],
|
||||
[117, "data padding"],
|
||||
] as const) {
|
||||
// 110-byte header, a three-byte NUL-terminated name, then one data byte.
|
||||
const decoded = gunzipSync(
|
||||
gzipCpio([{ name: "ab", mode: MODE.file, data: "x" }], format),
|
||||
);
|
||||
decoded[offset] = 1;
|
||||
const reader = new CpioArtifactReader(
|
||||
() => Promise.resolve(Readable.from([gzipSync(decoded)])),
|
||||
policy,
|
||||
);
|
||||
try {
|
||||
await expect(collect(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: `cpio ${label} contains nonzero bytes`,
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,875 +0,0 @@
|
||||
import { createHash, type Hash } from "node:crypto";
|
||||
import { open, type FileHandle } from "node:fs/promises";
|
||||
import { Readable, Transform, type TransformCallback } from "node:stream";
|
||||
import { createInflate, inflateSync } from "node:zlib";
|
||||
|
||||
import { DOMParser, type Element, type Node } from "@xmldom/xmldom";
|
||||
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
import {
|
||||
ArtifactDecodedBudget,
|
||||
ArtifactBudgetTransform,
|
||||
} from "./ArtifactDecodedBudget.js";
|
||||
import { artifactStreamPipeline } from "./ArtifactStreamPipeline.js";
|
||||
import {
|
||||
assertXarTocNodes,
|
||||
xarInteger as integer,
|
||||
xarHeapPosition,
|
||||
XarPathBudget,
|
||||
xarMode,
|
||||
} from "./XarValidation.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactChecksumObservation,
|
||||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
|
||||
const XAR_MAGIC = 0x78617221;
|
||||
/** A TOC describes members only; real installer TOCs are kilobytes. */
|
||||
const MAX_TOC_BYTES = 64 * 1024 * 1024;
|
||||
const READ_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
const CHECKSUM_ALGORITHMS: ReadonlyMap<string, string> = new Map([
|
||||
["sha1", "sha1"],
|
||||
["md5", "md5"],
|
||||
["sha256", "sha256"],
|
||||
["sha512", "sha512"],
|
||||
]);
|
||||
const DIGEST_BYTES: ReadonlyMap<string, number> = new Map([
|
||||
["sha1", 20],
|
||||
["md5", 16],
|
||||
["sha256", 32],
|
||||
["sha512", 64],
|
||||
]);
|
||||
|
||||
const cancelled = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure("cancelled", "PKG traversal was cancelled");
|
||||
};
|
||||
|
||||
/** One TOC `<data>` element: where a member's archived bytes live in the heap. */
|
||||
interface XarData {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly size: number;
|
||||
readonly encoding: string;
|
||||
readonly extractedChecksum:
|
||||
| { readonly algorithm: string; readonly value: string }
|
||||
| undefined;
|
||||
readonly unsupportedChecksum:
|
||||
| { readonly style: string; readonly value: string }
|
||||
| undefined;
|
||||
readonly archivedChecksum:
|
||||
| { readonly algorithm: string; readonly value: string }
|
||||
| undefined;
|
||||
readonly unsupportedArchivedChecksum:
|
||||
| { readonly style: string; readonly value: string }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
interface XarMember {
|
||||
readonly path: string;
|
||||
readonly kind: "file" | "directory" | "symlink";
|
||||
/** Raw TOC type when it is not file/directory/symlink (FIFO, device, ...). */
|
||||
readonly unsupportedType?: string;
|
||||
readonly mode: number | null;
|
||||
readonly data: XarData | undefined;
|
||||
readonly link: string | undefined;
|
||||
}
|
||||
|
||||
const isElement = (node: Node): node is Element => node.nodeType === 1;
|
||||
|
||||
/** Direct child elements with one tag name, in document order. */
|
||||
const childElements = (element: Element, name: string): Element[] =>
|
||||
Array.from(element.childNodes).filter(
|
||||
(child): child is Element => isElement(child) && child.tagName === name,
|
||||
);
|
||||
|
||||
const childElement = (element: Element, name: string): Element | undefined => {
|
||||
const children = childElements(element, name);
|
||||
if (children.length > 1)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC has duplicate <${name}> declarations in <${element.tagName}>`,
|
||||
);
|
||||
return children[0];
|
||||
};
|
||||
|
||||
// productbuild repeats an identical <name> when flattening a component PKG.
|
||||
// Preserve that observed producer form while rejecting contradictory names.
|
||||
const memberName = (file: Element): string | undefined => {
|
||||
const names = childElements(file, "name").map(
|
||||
(name) => name.textContent ?? "",
|
||||
);
|
||||
if (names.some((name) => name !== names[0]))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar TOC has conflicting <name> declarations",
|
||||
);
|
||||
return names[0];
|
||||
};
|
||||
|
||||
const textOf = (element: Element, name: string): string | undefined =>
|
||||
childElement(element, name)?.textContent ?? undefined;
|
||||
|
||||
const parseChecksum = (
|
||||
parent: Element,
|
||||
tag: string,
|
||||
):
|
||||
| { readonly algorithm: string; readonly value: string }
|
||||
| { readonly unsupported: string; readonly value: string }
|
||||
| undefined => {
|
||||
const element = childElement(parent, tag);
|
||||
if (element === undefined) return undefined;
|
||||
const style = element.getAttribute("style")?.toLowerCase() ?? "";
|
||||
const value = (element.textContent ?? "").trim().toLowerCase();
|
||||
if (CHECKSUM_ALGORITHMS.get(style) === undefined)
|
||||
return { unsupported: style || "(missing style)", value };
|
||||
const expected = DIGEST_BYTES.get(style);
|
||||
if (
|
||||
expected === undefined ||
|
||||
value.length !== expected * 2 ||
|
||||
!/^[0-9a-f]+$/u.test(value)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar ${tag} has malformed ${style} digest text`,
|
||||
);
|
||||
return { algorithm: style, value };
|
||||
};
|
||||
|
||||
const parseData = (file: Element): XarData | undefined => {
|
||||
const data = childElement(file, "data");
|
||||
if (data === undefined) return undefined;
|
||||
const extracted = parseChecksum(data, "extracted-checksum");
|
||||
const archived = parseChecksum(data, "archived-checksum");
|
||||
return {
|
||||
offset: integer(textOf(data, "offset"), "data offset"),
|
||||
length: integer(textOf(data, "length"), "data length"),
|
||||
size: integer(textOf(data, "size"), "data size"),
|
||||
encoding:
|
||||
childElement(data, "encoding")?.getAttribute("style") ??
|
||||
"application/octet-stream",
|
||||
extractedChecksum:
|
||||
extracted === undefined || "unsupported" in extracted
|
||||
? undefined
|
||||
: { algorithm: extracted.algorithm, value: extracted.value },
|
||||
unsupportedChecksum:
|
||||
extracted !== undefined && "unsupported" in extracted
|
||||
? { style: extracted.unsupported, value: extracted.value }
|
||||
: undefined,
|
||||
archivedChecksum:
|
||||
archived === undefined || "unsupported" in archived
|
||||
? undefined
|
||||
: { algorithm: archived.algorithm, value: archived.value },
|
||||
unsupportedArchivedChecksum:
|
||||
archived !== undefined && "unsupported" in archived
|
||||
? { style: archived.unsupported, value: archived.value }
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw members must describe their stored bytes exactly; otherwise the source
|
||||
* range would extend into adjacent heap entries.
|
||||
*/
|
||||
const assertRawSize = (member: XarMember, data: XarData | undefined): void => {
|
||||
if (
|
||||
data !== undefined &&
|
||||
data.encoding === "application/octet-stream" &&
|
||||
data.size !== data.length
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar member ${member.path} declares size ${data.size} with length ${data.length}`,
|
||||
);
|
||||
};
|
||||
|
||||
/** Entry limitations from the TOC record and nested-archive classification. */
|
||||
const memberLimitations = (
|
||||
member: XarMember,
|
||||
unsupportedChecksum: XarData["unsupportedChecksum"],
|
||||
unsupportedArchived: XarData["unsupportedArchivedChecksum"],
|
||||
classified: { readonly limitations: readonly string[] },
|
||||
): string[] => [
|
||||
...(member.kind === "symlink" && member.link !== undefined
|
||||
? [`Symlink target recorded in the TOC: ${member.link}`]
|
||||
: []),
|
||||
...(unsupportedChecksum === undefined
|
||||
? []
|
||||
: [
|
||||
`Member declares unsupported checksum ${unsupportedChecksum.style} ${unsupportedChecksum.value}; integrity not verified.`,
|
||||
]),
|
||||
...(unsupportedArchived === undefined
|
||||
? []
|
||||
: [
|
||||
`Member declares unsupported archived checksum ${unsupportedArchived.style} ${unsupportedArchived.value}; stored-byte integrity not verified.`,
|
||||
]),
|
||||
...(member.data !== undefined &&
|
||||
member.data.encoding !== "application/octet-stream" &&
|
||||
member.data.encoding !== "application/x-gzip"
|
||||
? [
|
||||
`Unsupported xar encoding ${member.data.encoding}; content is unavailable and was not expanded.`,
|
||||
]
|
||||
: []),
|
||||
...classified.limitations,
|
||||
];
|
||||
|
||||
/** File, directory, symlink, or an explicitly unsupported TOC type. */
|
||||
const classifyMemberType = (
|
||||
type: string | undefined,
|
||||
): { readonly kind: XarMember["kind"]; readonly unsupportedType?: string } => {
|
||||
if (type === "directory" || type === "symlink") return { kind: type };
|
||||
if (type === undefined || type === "file") return { kind: "file" };
|
||||
return { kind: "file", unsupportedType: type };
|
||||
};
|
||||
|
||||
/** Walk nested `<file>` elements in document order. */
|
||||
const collectMembers = (
|
||||
toc: Element,
|
||||
budget: ArtifactDecodedBudget,
|
||||
): XarMember[] => {
|
||||
const members: XarMember[] = [];
|
||||
const paths = new XarPathBudget();
|
||||
const pending: Array<{ readonly element: Element; readonly parent: string }> =
|
||||
childElements(toc, "file")
|
||||
.map((element) => ({ element, parent: "" }))
|
||||
.reverse();
|
||||
while (pending.length > 0) {
|
||||
const next = pending.pop();
|
||||
if (next === undefined) break;
|
||||
const name = memberName(next.element);
|
||||
if (
|
||||
name === undefined ||
|
||||
name === "" ||
|
||||
name.includes("/") ||
|
||||
name === "." ||
|
||||
name === ".."
|
||||
)
|
||||
throw new ArtifactReaderFailure("path", "xar member has an unsafe name");
|
||||
const path = paths.join(next.parent, name);
|
||||
budget.consumeEntry(path);
|
||||
const type = textOf(next.element, "type")?.trim();
|
||||
const mode = xarMode(textOf(next.element, "mode"));
|
||||
const { kind, unsupportedType } = classifyMemberType(type);
|
||||
if (kind !== "file" && childElement(next.element, "data") !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar non-file member declares data: ${path}`,
|
||||
);
|
||||
members.push({
|
||||
path,
|
||||
kind,
|
||||
...(unsupportedType === undefined ? {} : { unsupportedType }),
|
||||
mode,
|
||||
data: parseData(next.element),
|
||||
link: kind === "symlink" ? textOf(next.element, "link") : undefined,
|
||||
});
|
||||
const children = childElements(next.element, "file");
|
||||
for (let index = children.length - 1; index >= 0; index--) {
|
||||
const element = children[index];
|
||||
if (element !== undefined) pending.push({ element, parent: path });
|
||||
}
|
||||
}
|
||||
return members;
|
||||
};
|
||||
|
||||
/** Expose a member's extracted checksum so a caller can apply its own policy. */
|
||||
const declaredDigest = (
|
||||
checksum: { readonly algorithm: string; readonly value: string } | undefined,
|
||||
): Pick<ArtifactEntry, "declaredSha256" | "declaredChecksum"> => {
|
||||
if (checksum === undefined) return { declaredSha256: null };
|
||||
if (checksum.algorithm === "sha256")
|
||||
return { declaredSha256: checksum.value };
|
||||
const algorithm = checksum.algorithm;
|
||||
return algorithm === "sha1" || algorithm === "md5" || algorithm === "sha512"
|
||||
? {
|
||||
declaredSha256: null,
|
||||
declaredChecksum: { algorithm, value: checksum.value },
|
||||
}
|
||||
: { declaredSha256: null };
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute decoded-byte ceiling for one gzip member. The declared size is
|
||||
* attacker-controlled, so it cannot be the only inflation bound.
|
||||
*/
|
||||
const MAX_DECODED_MEMBER_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/** Bound decoded output to the TOC-declared extracted size. */
|
||||
class SizeBound extends Transform {
|
||||
#seen = 0;
|
||||
#cap: number;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly expected: number,
|
||||
private readonly budget: ArtifactDecodedBudget,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {
|
||||
super();
|
||||
this.#cap = Math.min(expected, MAX_DECODED_MEMBER_BYTES);
|
||||
}
|
||||
|
||||
override _transform(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
done: TransformCallback,
|
||||
): void {
|
||||
if (this.signal?.aborted) {
|
||||
done(
|
||||
new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
`xar member decoding cancelled: ${this.path}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.budget.consume(chunk.length, this.path);
|
||||
} catch (cause: unknown) {
|
||||
done(cause instanceof Error ? cause : new Error(String(cause)));
|
||||
return;
|
||||
}
|
||||
this.#seen += chunk.length;
|
||||
if (this.#seen > this.#cap) {
|
||||
done(
|
||||
new ArtifactReaderFailure(
|
||||
"limit",
|
||||
this.expected > MAX_DECODED_MEMBER_BYTES
|
||||
? `xar member ${this.path} decodes beyond the ${MAX_DECODED_MEMBER_BYTES}-byte member ceiling`
|
||||
: `xar member ${this.path} decodes beyond its declared ${this.expected} bytes`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
done(null, chunk);
|
||||
}
|
||||
|
||||
override _flush(done: TransformCallback): void {
|
||||
done(
|
||||
this.#seen === this.expected
|
||||
? null
|
||||
: new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar member ${this.path} decoded ${this.#seen} bytes, expected ${this.expected}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a member's extracted checksum as its decoded bytes stream past. */
|
||||
class ChecksumVerifier extends Transform {
|
||||
readonly #hash: Hash;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly expected: {
|
||||
readonly algorithm: string;
|
||||
readonly value: string;
|
||||
},
|
||||
private readonly record?: (
|
||||
observation: ArtifactChecksumObservation,
|
||||
) => void,
|
||||
) {
|
||||
super();
|
||||
this.#hash = createHash(expected.algorithm);
|
||||
}
|
||||
|
||||
override _transform(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
done: TransformCallback,
|
||||
): void {
|
||||
this.#hash.update(chunk);
|
||||
done(null, chunk);
|
||||
}
|
||||
|
||||
override _flush(done: TransformCallback): void {
|
||||
const observed = this.#hash.digest("hex");
|
||||
if (this.record !== undefined) {
|
||||
this.record({
|
||||
representation: "stored",
|
||||
algorithm: this.expected.algorithm,
|
||||
declared: this.expected.value,
|
||||
observed,
|
||||
});
|
||||
done();
|
||||
return;
|
||||
}
|
||||
done(
|
||||
observed === this.expected.value
|
||||
? null
|
||||
: new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`xar ${this.expected.algorithm} checksum disagrees with content: ${this.path} (declared ${this.expected.value}, observed ${observed})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** First bytes that identify an installer archive member's own format. */
|
||||
export type XarNestedArchive = "gzip-cpio";
|
||||
|
||||
/**
|
||||
* Read-only reader for xar archives, the container of flat installer
|
||||
* packages. Members are decoded and checksum-verified on demand. Gzip cpio
|
||||
* `Scripts` and `Payload` members are marked for nested expansion; pbzx
|
||||
* (LZMA) payloads are recorded as files with a limitation.
|
||||
*/
|
||||
export class XarArtifactReader implements ArtifactReader {
|
||||
readonly format = "pkg" as const;
|
||||
#handle: FileHandle | undefined;
|
||||
#heap = 0;
|
||||
#size = 0;
|
||||
readonly decodedBudget: ArtifactDecodedBudget;
|
||||
#members: readonly XarMember[] = [];
|
||||
readonly #byPath = new Map<string, XarMember>();
|
||||
#loading: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
readonly #streams = new Set<Readable>();
|
||||
readonly #integrity = new Map<string, ArtifactChecksumObservation[]>();
|
||||
|
||||
readonly #verifyChecksums: boolean;
|
||||
|
||||
/**
|
||||
* @param options.verifyChecksums Fail `open()` streams whose bytes disagree
|
||||
* with the member's extracted checksum (default). Inventory passes false and
|
||||
* verifies the declared checksum itself under the caller's integrity policy.
|
||||
* Archived checksums are always computed. When inventory owns the policy,
|
||||
* integrityObservations exposes their declared/observed values after reading.
|
||||
*/
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
options: {
|
||||
readonly verifyChecksums?: boolean;
|
||||
readonly decodedBudget?: ArtifactDecodedBudget;
|
||||
} = {},
|
||||
) {
|
||||
this.#verifyChecksums = options.verifyChecksums ?? true;
|
||||
this.decodedBudget = options.decodedBudget ?? new ArtifactDecodedBudget();
|
||||
}
|
||||
|
||||
async #load(signal?: AbortSignal): Promise<void> {
|
||||
if (this.#closed)
|
||||
throw new ArtifactReaderFailure("unavailable", "xar archive is closed");
|
||||
this.#loading ??= this.#readToc(signal);
|
||||
await this.#loading;
|
||||
}
|
||||
|
||||
/** Load/index once; a rejected parse remains rejected, never a partial inventory. */
|
||||
async #readToc(signal?: AbortSignal): Promise<void> {
|
||||
cancelled(signal);
|
||||
const handle = await open(this.path, "r");
|
||||
this.#handle = handle;
|
||||
this.#size = (await handle.stat()).size;
|
||||
const header = Buffer.alloc(28);
|
||||
const { bytesRead } = await handle.read(header, 0, 28, 0);
|
||||
if (bytesRead < 28 || header.readUInt32BE(0) !== XAR_MAGIC)
|
||||
throw new ArtifactReaderFailure("format", "File is not a xar archive");
|
||||
const version = header.readUInt16BE(6);
|
||||
if (version !== 1)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Unsupported xar header version ${version}; expected 1`,
|
||||
);
|
||||
const headerSize = header.readUInt16BE(4);
|
||||
const tocCompressed = header.readBigUInt64BE(8);
|
||||
const tocSize = header.readBigUInt64BE(16);
|
||||
// Fixed-header checksum algorithm at offset 24: 0 is none, 1 is SHA-1,
|
||||
// 2 is MD5. A declared algorithm requires a matching <checksum> element.
|
||||
const headerChecksumAlg = header.readUInt32BE(24);
|
||||
if (headerSize < 28)
|
||||
throw new ArtifactReaderFailure("format", "xar header is too short");
|
||||
if (
|
||||
tocCompressed > BigInt(MAX_TOC_BYTES) ||
|
||||
tocSize > BigInt(MAX_TOC_BYTES)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`xar TOC exceeds the ${MAX_TOC_BYTES}-byte limit`,
|
||||
);
|
||||
const compressed = Buffer.alloc(Number(tocCompressed));
|
||||
const toc = await handle.read(compressed, 0, compressed.length, headerSize);
|
||||
if (toc.bytesRead < compressed.length)
|
||||
throw new ArtifactReaderFailure("format", "xar TOC is truncated");
|
||||
this.#heap = headerSize + compressed.length;
|
||||
let xml: string;
|
||||
try {
|
||||
const inflated = inflateSync(compressed, {
|
||||
maxOutputLength: MAX_TOC_BYTES,
|
||||
});
|
||||
if (BigInt(inflated.length) !== tocSize)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC decoded ${inflated.length} bytes, expected ${tocSize}`,
|
||||
);
|
||||
try {
|
||||
xml = new TextDecoder("utf-8", { fatal: true }).decode(inflated);
|
||||
} catch (cause: unknown) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar TOC is not valid UTF-8",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
assertXarTocNodes(xml);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw new ArtifactReaderFailure("format", "xar TOC is not zlib data", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
let document;
|
||||
try {
|
||||
document = new DOMParser({
|
||||
onError: (level, message) => {
|
||||
if (level !== "warning") throw new Error(message);
|
||||
},
|
||||
}).parseFromString(xml, "text/xml");
|
||||
} catch (cause: unknown) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar TOC is not well-formed XML",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const tocElement = root === null ? undefined : childElement(root, "toc");
|
||||
if (tocElement === undefined)
|
||||
throw new ArtifactReaderFailure("format", "xar TOC has no <toc> element");
|
||||
await this.#verifyToc(tocElement, compressed, headerChecksumAlg);
|
||||
this.#members = collectMembers(tocElement, this.decodedBudget);
|
||||
for (const member of this.#members) {
|
||||
if (member.data !== undefined)
|
||||
xarHeapPosition(
|
||||
this.#heap,
|
||||
member.data.offset,
|
||||
member.data.length,
|
||||
this.#size,
|
||||
);
|
||||
if (this.#byPath.has(member.path))
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Duplicate xar member path: ${member.path}`,
|
||||
);
|
||||
this.#byPath.set(member.path, member);
|
||||
}
|
||||
}
|
||||
|
||||
/** The TOC checksum in the heap covers the compressed TOC bytes. */
|
||||
async #verifyToc(
|
||||
toc: Element,
|
||||
compressed: Buffer,
|
||||
headerAlg: number,
|
||||
): Promise<void> {
|
||||
// Fixed-header checksum IDs follow libarchive's XAR reader: 0 is none,
|
||||
// 1 is SHA-1, 2 is MD5, 3 is SHA-256, 4 is SHA-512.
|
||||
const expectedStyle =
|
||||
headerAlg === 0
|
||||
? null
|
||||
: headerAlg === 1
|
||||
? "sha1"
|
||||
: headerAlg === 2
|
||||
? "md5"
|
||||
: headerAlg === 3
|
||||
? "sha256"
|
||||
: headerAlg === 4
|
||||
? "sha512"
|
||||
: undefined;
|
||||
if (expectedStyle === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar header declares unsupported TOC checksum algorithm ${headerAlg}`,
|
||||
);
|
||||
const checksum = childElement(toc, "checksum");
|
||||
if (expectedStyle === null && checksum !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar header declares no TOC checksum but the TOC supplies one",
|
||||
);
|
||||
if (checksum === undefined) {
|
||||
if (headerAlg !== 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar header declares a TOC checksum but the TOC has no <checksum> element",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const style = checksum.getAttribute("style")?.toLowerCase() ?? "";
|
||||
if (expectedStyle !== null && style !== expectedStyle)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC checksum style ${style || "(missing)"} does not match header algorithm ${expectedStyle ?? "none"}`,
|
||||
);
|
||||
const algorithm = CHECKSUM_ALGORITHMS.get(style);
|
||||
if (algorithm === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC declares unsupported checksum style ${style || "(missing)"}`,
|
||||
);
|
||||
const size = integer(textOf(checksum, "size"), "checksum size");
|
||||
// The stored checksum is exactly one digest; never allocate a declared size.
|
||||
if (size !== DIGEST_BYTES.get(style))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC declares a ${size}-byte ${style} checksum`,
|
||||
);
|
||||
const stored = await this.#heapBytes(
|
||||
integer(textOf(checksum, "offset"), "checksum offset"),
|
||||
size,
|
||||
);
|
||||
if (!createHash(algorithm).update(compressed).digest().equals(stored))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"xar TOC checksum disagrees with the TOC",
|
||||
);
|
||||
}
|
||||
|
||||
async #heapBytes(offset: number, length: number): Promise<Buffer> {
|
||||
const handle = this.#handle;
|
||||
if (handle === undefined)
|
||||
throw new ArtifactReaderFailure("unavailable", "xar archive is closed");
|
||||
const position = xarHeapPosition(this.#heap, offset, length, this.#size);
|
||||
const bytes = Buffer.alloc(length);
|
||||
let done = 0;
|
||||
while (done < length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
done,
|
||||
length - done,
|
||||
position + done,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar member extends beyond the archive",
|
||||
);
|
||||
done += bytesRead;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Classify a raw-stored installer member by its leading bytes. */
|
||||
async #nestedArchive(member: XarMember): Promise<{
|
||||
readonly nested: XarNestedArchive | undefined;
|
||||
readonly limitations: readonly string[];
|
||||
}> {
|
||||
const name = member.path.split("/").at(-1);
|
||||
const data = member.data;
|
||||
if (
|
||||
(name !== "Payload" && name !== "Scripts") ||
|
||||
data === undefined ||
|
||||
data.length < 4
|
||||
)
|
||||
return { nested: undefined, limitations: [] };
|
||||
if (data.encoding !== "application/octet-stream")
|
||||
return {
|
||||
nested: undefined,
|
||||
limitations: [
|
||||
`${name} is stored with ${data.encoding}; its archive is not expanded.`,
|
||||
],
|
||||
};
|
||||
const head = await this.#heapBytes(data.offset, 4);
|
||||
if (head[0] === 0x1f && head[1] === 0x8b)
|
||||
return { nested: "gzip-cpio", limitations: [] };
|
||||
if (head.toString("latin1") === "pbzx")
|
||||
return {
|
||||
nested: undefined,
|
||||
limitations: [
|
||||
`${name} is a pbzx (LZMA) archive; its members are not expanded. Expand it with pkgutil --expand-full to inspect them.`,
|
||||
],
|
||||
};
|
||||
return {
|
||||
nested: undefined,
|
||||
limitations: [
|
||||
`${name} has an unrecognized archive format and is not expanded.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
await this.#load(signal);
|
||||
for (const member of this.#members) {
|
||||
cancelled(signal);
|
||||
yield await this.#memberEntry(member);
|
||||
}
|
||||
}
|
||||
|
||||
/** Project one TOC member as an archive-neutral entry. */
|
||||
async #memberEntry(member: XarMember): Promise<ArtifactEntry> {
|
||||
if (member.unsupportedType !== undefined)
|
||||
return {
|
||||
path: member.path,
|
||||
kind: "file",
|
||||
declaredSize: null,
|
||||
compressedSize: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256: null,
|
||||
unpacked: false,
|
||||
limitations: [
|
||||
`Unsupported xar member type ${member.unsupportedType}; content not expanded.`,
|
||||
],
|
||||
adapterKey: member.path,
|
||||
contentUnavailable: true,
|
||||
};
|
||||
const data = member.data;
|
||||
assertRawSize(member, data);
|
||||
const unsupportedChecksum = data?.unsupportedChecksum;
|
||||
const unsupportedArchived = data?.unsupportedArchivedChecksum;
|
||||
const unsupportedEncoding =
|
||||
data !== undefined &&
|
||||
data.encoding !== "application/octet-stream" &&
|
||||
data.encoding !== "application/x-gzip";
|
||||
const classified =
|
||||
member.kind === "file" && !unsupportedEncoding
|
||||
? await this.#nestedArchive(member)
|
||||
: {
|
||||
nested: undefined as XarNestedArchive | undefined,
|
||||
limitations: [] as readonly string[],
|
||||
};
|
||||
return {
|
||||
path: member.path,
|
||||
kind: member.kind,
|
||||
declaredSize: member.data?.size ?? (member.kind === "file" ? 0 : null),
|
||||
compressedSize: member.data?.length ?? null,
|
||||
executable: member.mode !== null && (member.mode & 0o111) !== 0,
|
||||
encrypted: false,
|
||||
byteOffset:
|
||||
member.data?.encoding === "application/octet-stream"
|
||||
? this.#heap + member.data.offset
|
||||
: null,
|
||||
...declaredDigest(member.data?.extractedChecksum),
|
||||
unpacked: false,
|
||||
limitations: memberLimitations(
|
||||
member,
|
||||
unsupportedChecksum,
|
||||
unsupportedArchived,
|
||||
classified,
|
||||
),
|
||||
adapterKey: member.path,
|
||||
...(unsupportedEncoding ? { contentUnavailable: true } : {}),
|
||||
...(classified.nested === undefined
|
||||
? {}
|
||||
: { nestedArchive: classified.nested }),
|
||||
};
|
||||
}
|
||||
|
||||
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
||||
cancelled(signal);
|
||||
this.#integrity.delete(entry.adapterKey);
|
||||
await this.#load(signal);
|
||||
const member = this.#byPath.get(entry.adapterKey);
|
||||
if (member === undefined || member.kind !== "file")
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`xar member is not a file: ${entry.path}`,
|
||||
);
|
||||
if (member.unsupportedType !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`xar member ${entry.path} has unsupported type ${member.unsupportedType}`,
|
||||
);
|
||||
const data = member.data;
|
||||
if (data === undefined) return Readable.from([]);
|
||||
if (entry.contentUnavailable === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`xar member ${entry.path} is not expandable`,
|
||||
);
|
||||
// Raw members must describe their stored bytes exactly.
|
||||
if (
|
||||
data.encoding === "application/octet-stream" &&
|
||||
data.size !== data.length
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar member ${entry.path} declares size ${data.size} with length ${data.length}`,
|
||||
);
|
||||
// Validate before creating any streams: otherwise an early throw would
|
||||
// orphan a flowing source that keeps reading after close().
|
||||
if (
|
||||
data.encoding !== "application/octet-stream" &&
|
||||
data.encoding !== "application/x-gzip"
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar member ${entry.path} uses unsupported encoding ${data.encoding}`,
|
||||
);
|
||||
// Archived checksums cover the stored stream, which inventory's scanner
|
||||
// never observes: it hashes decoded bytes. Always verify them here; the
|
||||
// scanner's per-entry recovery still honors record-and-continue.
|
||||
const archived = data.archivedChecksum;
|
||||
const archivedVerifier =
|
||||
archived === undefined
|
||||
? undefined
|
||||
: new ChecksumVerifier(
|
||||
`${entry.path} (archived)`,
|
||||
archived,
|
||||
this.#verifyChecksums
|
||||
? undefined
|
||||
: (observation) => {
|
||||
this.#integrity.set(entry.adapterKey, [observation]);
|
||||
},
|
||||
);
|
||||
const stages: Transform[] = [
|
||||
new ArtifactBudgetTransform(this.decodedBudget, entry.path, signal),
|
||||
];
|
||||
if (archivedVerifier !== undefined) stages.push(archivedVerifier);
|
||||
if (data.encoding === "application/x-gzip")
|
||||
stages.push(
|
||||
createInflate(),
|
||||
new SizeBound(entry.path, data.size, this.decodedBudget, signal),
|
||||
);
|
||||
const checksum = data.extractedChecksum;
|
||||
if (checksum !== undefined && this.#verifyChecksums)
|
||||
stages.push(new ChecksumVerifier(entry.path, checksum));
|
||||
const stream = artifactStreamPipeline(
|
||||
Readable.from(this.#chunks(data.offset, data.length, signal)),
|
||||
stages,
|
||||
{ path: entry.path, signal },
|
||||
);
|
||||
this.#streams.add(stream);
|
||||
stream.once("close", () => this.#streams.delete(stream));
|
||||
return stream;
|
||||
}
|
||||
|
||||
async *#chunks(
|
||||
offset: number,
|
||||
length: number,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<Buffer> {
|
||||
for (let done = 0; done < length;) {
|
||||
cancelled(signal);
|
||||
const size = Math.min(READ_CHUNK_BYTES, length - done);
|
||||
yield await this.#heapBytes(offset + done, size);
|
||||
done += size;
|
||||
}
|
||||
}
|
||||
|
||||
provenance(): readonly ArtifactCommand[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Stored-byte checksums computed while consuming the member stream. */
|
||||
integrityObservations(
|
||||
entry: ArtifactEntry,
|
||||
): readonly ArtifactChecksumObservation[] {
|
||||
return this.#integrity.get(entry.adapterKey) ?? [];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
for (const stream of this.#streams) stream.destroy();
|
||||
await this.#loading?.catch(() => undefined);
|
||||
const handle = this.#handle;
|
||||
this.#handle = undefined;
|
||||
this.#members = [];
|
||||
this.#byPath.clear();
|
||||
this.#integrity.clear();
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
/**
|
||||
* Node ceiling for one TOC. The byte cap still allows millions of tiny
|
||||
* elements, comments, and processing instructions. The DOM is built before
|
||||
* member collection can charge the metadata budget.
|
||||
*/
|
||||
const MAX_TOC_NODES = 100_000;
|
||||
|
||||
/** Bound DOM-producing markup, including comments, CDATA and processing instructions. */
|
||||
export const assertXarTocNodes = (xml: string): void => {
|
||||
let nodes = 0;
|
||||
let index = xml.indexOf("<");
|
||||
while (index !== -1) {
|
||||
if (xml[index + 1] !== "/") {
|
||||
nodes += 1;
|
||||
if (nodes > MAX_TOC_NODES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`xar TOC exceeds ${MAX_TOC_NODES} XML nodes`,
|
||||
);
|
||||
}
|
||||
const terminator = xml.startsWith("<!--", index)
|
||||
? "-->"
|
||||
: xml.startsWith("<![CDATA[", index)
|
||||
? "]]>"
|
||||
: xml.startsWith("<?", index)
|
||||
? "?>"
|
||||
: ">";
|
||||
const end = xml.indexOf(terminator, index + 1);
|
||||
if (end === -1) return; // The XML parser reports malformed framing.
|
||||
index = xml.indexOf("<", end + terminator.length);
|
||||
}
|
||||
};
|
||||
|
||||
/** Required XAR integer syntax: nonempty unsigned decimal, exactly representable. */
|
||||
export const xarInteger = (
|
||||
value: string | undefined,
|
||||
label: string,
|
||||
): number => {
|
||||
const text = value?.trim() ?? "";
|
||||
const parsed = /^\d+$/u.test(text) ? Number(text) : Number.NaN;
|
||||
if (!Number.isSafeInteger(parsed))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`xar TOC has an invalid ${label}`,
|
||||
);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Optional mode metadata is accepted only as a complete unsigned octal field. */
|
||||
export const xarMode = (value: string | undefined): number | null => {
|
||||
if (value === undefined) return null;
|
||||
const text = value.trim();
|
||||
const mode = /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN;
|
||||
if (!Number.isSafeInteger(mode))
|
||||
throw new ArtifactReaderFailure("format", "xar TOC has an invalid mode");
|
||||
return mode;
|
||||
};
|
||||
|
||||
/** Validate the entire absolute heap range before allocating or requesting it. */
|
||||
export const xarHeapPosition = (
|
||||
heap: number,
|
||||
offset: number,
|
||||
length: number,
|
||||
size: number,
|
||||
): number => {
|
||||
const position = heap + offset;
|
||||
const end = position + length;
|
||||
if (
|
||||
![position, end, offset, length].every(Number.isSafeInteger) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
end > size
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"xar member has an invalid or out-of-file heap extent",
|
||||
);
|
||||
return position;
|
||||
};
|
||||
|
||||
/** Bound retained path text before constructing paths from deeply nested TOCs. */
|
||||
export class XarPathBudget {
|
||||
#used = 0;
|
||||
private readonly maximumPathBytes = 4096;
|
||||
private readonly maximumTotalBytes = 16 * 1024 * 1024;
|
||||
|
||||
/** Form the next path only when individual and cumulative budgets permit it. */
|
||||
join(parent: string, name: string): string {
|
||||
const bytes =
|
||||
Buffer.byteLength(parent) +
|
||||
Buffer.byteLength(name) +
|
||||
(parent.length > 0 ? 1 : 0);
|
||||
if (
|
||||
bytes > this.maximumPathBytes ||
|
||||
bytes > this.maximumTotalBytes - this.#used
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`xar TOC path text exceeds its ${this.maximumPathBytes}-byte path or ${this.maximumTotalBytes}-byte cumulative budget`,
|
||||
);
|
||||
this.#used += bytes;
|
||||
return parent === "" ? name : `${parent}/${name}`;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { lstat, realpath } from "node:fs/promises";
|
||||
|
||||
import { canonicalDigest } from "../../domain/comparisonSemantics.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import { XarArtifactReader } from "../XarArtifactReader.js";
|
||||
import { ArtifactPathRegistry } from "../ArtifactPaths.js";
|
||||
import { visitArtifactTree } from "../ArtifactTraversal.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
} from "../ArtifactPaths.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
@@ -43,7 +44,6 @@ export const extractArtifact = async (
|
||||
const selectedOccurrences = snapshot.occurrences.filter(
|
||||
(occurrence) =>
|
||||
(occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") &&
|
||||
occurrence.hash_status !== "unavailable" &&
|
||||
occurrence.logical_path !== ".",
|
||||
);
|
||||
const selectedIds = new Set(
|
||||
@@ -63,11 +63,6 @@ export const extractArtifact = async (
|
||||
manifest: snapshot.manifest,
|
||||
occurrences,
|
||||
nodes,
|
||||
unavailablePaths: new Set(
|
||||
snapshot.occurrences
|
||||
.filter(({ hash_status }) => hash_status === "unavailable")
|
||||
.map(({ logical_path }) => logical_path),
|
||||
),
|
||||
};
|
||||
const selected = selectedOccurrences.map((occurrence) => {
|
||||
if (
|
||||
@@ -133,52 +128,35 @@ const materializeSelection = async ({
|
||||
try {
|
||||
const materialized: SelectedOccurrence[] = [];
|
||||
const registry = new ArtifactPathRegistry();
|
||||
await visitArtifactTree(
|
||||
reader,
|
||||
async ({ reader: currentReader, entry, path, container: expandable }) => {
|
||||
// Traverse expanded PKG Payload/Scripts like inventory does, so nested
|
||||
// members are materialized instead of silently skipped. An expanded
|
||||
// member acts as a container: register it as a directory and leave its
|
||||
// archive bytes to its children, since a file and a directory cannot
|
||||
// share one output path.
|
||||
registry.add(path, expandable ? "directory" : entry.kind);
|
||||
if (expandable) {
|
||||
return true;
|
||||
}
|
||||
const selectedItem = byPath.get(path);
|
||||
if (selectedItem === undefined) {
|
||||
if (
|
||||
entry.contentUnavailable === true &&
|
||||
inventory.unavailablePaths.has(path)
|
||||
)
|
||||
return false;
|
||||
if (entry.kind === "file" || entry.kind === "slice")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Regular artifact entry is missing from inventory: ${path}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
preflight(entry);
|
||||
const stream = await currentReader.open(entry, signal);
|
||||
const written = await output.write(
|
||||
path,
|
||||
stream,
|
||||
selectedItem.node.sha256,
|
||||
signal,
|
||||
);
|
||||
extracted.push({
|
||||
artifact_id: selectedItem.node.artifact_id,
|
||||
relative_path: written.relativePath,
|
||||
sha256: written.sha256,
|
||||
bytes_written: written.bytesWritten,
|
||||
created: true,
|
||||
});
|
||||
materialized.push(selectedItem);
|
||||
return false;
|
||||
},
|
||||
{ signal, expandAsar: false },
|
||||
);
|
||||
for await (const entry of reader.entries(signal)) {
|
||||
const path = normalizeArtifactPath(entry.path);
|
||||
registry.add(path, entry.kind);
|
||||
const selectedItem = byPath.get(path);
|
||||
if (selectedItem === undefined) {
|
||||
if (entry.kind === "file" || entry.kind === "slice")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Regular artifact entry is missing from inventory: ${path}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
preflight(entry);
|
||||
const stream = await reader.open(entry, signal);
|
||||
const written = await output.write(
|
||||
path,
|
||||
stream,
|
||||
selectedItem.node.sha256,
|
||||
signal,
|
||||
);
|
||||
extracted.push({
|
||||
artifact_id: selectedItem.node.artifact_id,
|
||||
relative_path: written.relativePath,
|
||||
sha256: written.sha256,
|
||||
bytes_written: written.bytesWritten,
|
||||
created: true,
|
||||
});
|
||||
materialized.push(selectedItem);
|
||||
}
|
||||
await reader.close();
|
||||
readerClosed = true;
|
||||
extracted.sort((left, right) =>
|
||||
@@ -229,11 +207,7 @@ const createExtractionResult = (
|
||||
cleanup: { attempted: false, verified: true, residual_paths: [] },
|
||||
provenance: [],
|
||||
limitations: [
|
||||
"All available selected regular files were materialized; expanded PKG Payload/Scripts archives are represented by their extracted children.",
|
||||
...[...inventory.unavailablePaths].map(
|
||||
(path) =>
|
||||
`Unavailable member ${path} was not extracted; see its inventory occurrence for the reason.`,
|
||||
),
|
||||
"All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.",
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -242,7 +216,6 @@ interface LoadedInventory {
|
||||
readonly manifest: ArtifactGraphManifest;
|
||||
readonly occurrences: ReadonlyMap<string, ArtifactOccurrence>;
|
||||
readonly nodes: ReadonlyMap<string, ArtifactNode>;
|
||||
readonly unavailablePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const collectOccurrences = (
|
||||
@@ -283,7 +256,6 @@ const createReader = async (
|
||||
)
|
||||
return new ZipArtifactReader(path, format);
|
||||
if (format === "mach-o") return new MachOSliceArtifactReader(path);
|
||||
if (format === "pkg") return new XarArtifactReader(path);
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
|
||||
@@ -50,12 +50,7 @@ export const createOccurrence = (
|
||||
compressed_size: entry.compressedSize,
|
||||
executable: entry.executable,
|
||||
encrypted: entry.encrypted,
|
||||
hash_status:
|
||||
entry.integrityMismatched === true
|
||||
? "mismatched"
|
||||
: entry.encrypted || entry.contentUnavailable === true
|
||||
? "unavailable"
|
||||
: "not-hashed",
|
||||
hash_status: entry.encrypted ? "unavailable" : "not-hashed",
|
||||
source_location:
|
||||
entry.byteOffset === null || entry.declaredSize === null
|
||||
? null
|
||||
@@ -109,14 +104,6 @@ export const materializeDirectoryNodes = (
|
||||
format: "directory",
|
||||
executable: false,
|
||||
contentState: "virtual",
|
||||
...(directory.hash_status === "mismatched"
|
||||
? {
|
||||
limitations: [
|
||||
...directory.limitations,
|
||||
"Directory structure identity is derived; its declared member integrity metadata disagreed.",
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const existing = nodes.get(node.artifact_id);
|
||||
nodes.set(
|
||||
@@ -124,8 +111,7 @@ export const materializeDirectoryNodes = (
|
||||
existing?.kind === "framework" ? existing : node,
|
||||
);
|
||||
directory.artifact_id = node.artifact_id;
|
||||
if (directory.hash_status !== "mismatched")
|
||||
directory.hash_status = "verified";
|
||||
directory.hash_status = "verified";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ArtifactReader } from "../ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
|
||||
import { XarArtifactReader } from "../XarArtifactReader.js";
|
||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
|
||||
@@ -11,8 +10,6 @@ export const createReader = async (
|
||||
path: string,
|
||||
format: ArtifactNode["format"],
|
||||
signal?: AbortSignal,
|
||||
/** The caller verifies declared member checksums itself (inventory scans do). */
|
||||
options: { readonly callerVerifiesChecksums?: boolean } = {},
|
||||
): Promise<ArtifactReader | undefined> => {
|
||||
switch (format) {
|
||||
case "directory":
|
||||
@@ -25,10 +22,6 @@ export const createReader = async (
|
||||
return new ZipArtifactReader(path, format);
|
||||
case "asar":
|
||||
return new AsarArtifactReader(path);
|
||||
case "pkg":
|
||||
return new XarArtifactReader(path, {
|
||||
verifyChecksums: options.callerVerifiesChecksums !== true,
|
||||
});
|
||||
case "mach-o-universal":
|
||||
return process.platform === "darwin"
|
||||
? new MachOSliceArtifactReader(path)
|
||||
@@ -46,9 +39,9 @@ export const inventoryLimitations = (
|
||||
reader: ArtifactReader | undefined,
|
||||
): string[] => {
|
||||
if (reader !== undefined) return [];
|
||||
if (format === "dmg")
|
||||
if (format === "dmg" || format === "pkg")
|
||||
return [
|
||||
"DMG root hash is observed; child inventory requires a native macOS adapter.",
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
|
||||
];
|
||||
if (format === "mach-o-universal" && process.platform !== "darwin")
|
||||
return ["Universal Mach-O slices require the native macOS lipo adapter."];
|
||||
|
||||
@@ -45,10 +45,7 @@ export const scanCanonicalArtifactInventory = async (
|
||||
const rootDigest = metadata.isDirectory()
|
||||
? null
|
||||
: await hashReadable(createReadStream(path), options.signal);
|
||||
// The scan applies the integrity policy to declared member checksums.
|
||||
const reader = await createReader(path, rootFormat, options.signal, {
|
||||
callerVerifiesChecksums: true,
|
||||
});
|
||||
const reader = await createReader(path, rootFormat, options.signal);
|
||||
|
||||
try {
|
||||
const { nodes, occurrences, pendingContradictions } = await scanReader(
|
||||
@@ -151,14 +148,7 @@ const buildInventorySnapshot = async (
|
||||
edges: orderedEdges,
|
||||
provenance: reader?.provenance() ?? [],
|
||||
integrity_contradictions: integrityContradictions,
|
||||
limitations: buildLimitations(
|
||||
rootFormat,
|
||||
reader,
|
||||
integrityContradictions,
|
||||
input.occurrences.filter(
|
||||
({ hash_status: status }) => status === "mismatched",
|
||||
).length,
|
||||
),
|
||||
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -271,22 +261,11 @@ const buildLimitations = (
|
||||
rootFormat: ArtifactNode["format"],
|
||||
reader: ArtifactReader | undefined,
|
||||
integrityContradictions: readonly IntegrityContradiction[],
|
||||
mismatchedOccurrences: number,
|
||||
): string[] => {
|
||||
// integrity_contradictions carry SHA-256 values; other declared checksums
|
||||
// (xar SHA-1/MD5) are recorded on their occurrences.
|
||||
const checksumOnly = mismatchedOccurrences - integrityContradictions.length;
|
||||
return [
|
||||
...inventoryLimitations(rootFormat, reader),
|
||||
...(integrityContradictions.length === 0
|
||||
? []
|
||||
: [
|
||||
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
|
||||
]),
|
||||
...(checksumOnly <= 0
|
||||
? []
|
||||
: [
|
||||
`${String(checksumOnly)} member(s) disagree with a declared non-SHA-256 checksum; their occurrences are mismatched and name the declared and observed values.`,
|
||||
]),
|
||||
];
|
||||
};
|
||||
): string[] => [
|
||||
...inventoryLimitations(rootFormat, reader),
|
||||
...(integrityContradictions.length === 0
|
||||
? []
|
||||
: [
|
||||
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ArtifactBudgetExceeded } from "../ArtifactDecodedBudget.js";
|
||||
import { ArtifactPathRegistry } from "../ArtifactPaths.js";
|
||||
import { visitArtifactTree } from "../ArtifactTraversal.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
} from "../ArtifactPaths.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactChecksumObservation,
|
||||
} from "../ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
classifyArtifactContent,
|
||||
@@ -73,7 +76,7 @@ export const scanReader = async (
|
||||
registry: new ArtifactPathRegistry(),
|
||||
expandedContainerIds: new Set<string>(),
|
||||
};
|
||||
await visitArtifactEntries(context, reader);
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
// against the complete index before directory identities are materialized.
|
||||
for (const occurrence of occurrences)
|
||||
@@ -89,42 +92,55 @@ export const scanReader = async (
|
||||
const visitArtifactEntries = async (
|
||||
context: ScanContext,
|
||||
currentReader: ArtifactReader,
|
||||
prefix: string,
|
||||
): Promise<void> => {
|
||||
await visitArtifactTree(
|
||||
currentReader,
|
||||
async ({ reader, entry, path: logicalPath, container: expandable }) => {
|
||||
context.registry.add(logicalPath, expandable ? "directory" : entry.kind);
|
||||
const stack: Array<{
|
||||
readonly reader: ArtifactReader;
|
||||
readonly prefix: string;
|
||||
readonly iterator: AsyncIterator<ArtifactEntry>;
|
||||
readonly owned: boolean;
|
||||
}> = [
|
||||
{
|
||||
reader: currentReader,
|
||||
prefix,
|
||||
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
|
||||
owned: false,
|
||||
},
|
||||
];
|
||||
try {
|
||||
while (stack.length > 0) {
|
||||
const frame = stack.at(-1);
|
||||
if (frame === undefined) break;
|
||||
const next = await frame.iterator.next();
|
||||
if (next.done) {
|
||||
stack.pop();
|
||||
if (frame.owned) await frame.reader.close();
|
||||
continue;
|
||||
}
|
||||
const entry = next.value;
|
||||
const logicalPath = normalizeArtifactPath(
|
||||
frame.prefix.length === 0
|
||||
? entry.path
|
||||
: `${frame.prefix}/${entry.path}`,
|
||||
);
|
||||
const expandableAsar = isExpandableAsar(entry, logicalPath);
|
||||
context.registry.add(
|
||||
logicalPath,
|
||||
expandableAsar ? "directory" : entry.kind,
|
||||
);
|
||||
const occurrence = createOccurrence(entry, logicalPath, null);
|
||||
let digested: DigestedEntry | undefined;
|
||||
let digested:
|
||||
| { readonly node: ArtifactNode; readonly mismatched: boolean }
|
||||
| undefined;
|
||||
try {
|
||||
digested = await digestArtifactEntry(
|
||||
context,
|
||||
reader,
|
||||
frame.reader,
|
||||
entry,
|
||||
logicalPath,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (!isUnavailableUnpackedEntry(cause, entry)) {
|
||||
// A streamed CRC, size-bound, or format failure is per-member
|
||||
// evidence loss under record-and-continue: keep the occurrence
|
||||
// unavailable with its diagnostic, without inventing a node.
|
||||
if (
|
||||
context.integrity.mode !== "record-and-continue" ||
|
||||
cause instanceof ArtifactBudgetExceeded ||
|
||||
!(cause instanceof ArtifactReaderFailure) ||
|
||||
(cause.reason !== "integrity" &&
|
||||
cause.reason !== "limit" &&
|
||||
cause.reason !== "format")
|
||||
)
|
||||
throw cause;
|
||||
occurrence.hash_status = "unavailable";
|
||||
occurrence.limitations.push(
|
||||
`Member content could not be verified: ${cause.message}`,
|
||||
);
|
||||
context.occurrences.push(occurrence);
|
||||
context.occurrenceByPath.set(logicalPath, occurrence);
|
||||
return false;
|
||||
}
|
||||
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
|
||||
occurrence.hash_status = "unavailable";
|
||||
occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION);
|
||||
}
|
||||
@@ -137,60 +153,32 @@ const visitArtifactEntries = async (
|
||||
if (digested.mismatched)
|
||||
occurrence.limitations.push(
|
||||
"Declared integrity metadata contradicts observed bytes.",
|
||||
...digested.mismatchDetails,
|
||||
);
|
||||
}
|
||||
context.occurrences.push(occurrence);
|
||||
context.occurrenceByPath.set(logicalPath, occurrence);
|
||||
if (expandable && digested?.mismatched !== true) {
|
||||
// Only traversed containers can own members, not opaque archive-named files.
|
||||
if (expandableAsar && digested?.mismatched !== true) {
|
||||
const nested = new AsarArtifactReader(entry.adapterKey);
|
||||
// Only traversed containers can own members, not opaque ASAR-named files.
|
||||
context.expandedContainerIds.add(occurrence.occurrence_id);
|
||||
return true;
|
||||
stack.push({
|
||||
reader: nested,
|
||||
prefix: logicalPath,
|
||||
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
|
||||
owned: true,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ signal: context.signal, integrity: context.integrity.mode },
|
||||
);
|
||||
};
|
||||
|
||||
interface DigestedEntry {
|
||||
readonly node: ArtifactNode;
|
||||
readonly mismatched: boolean;
|
||||
/** Declared and observed values of a non-SHA-256 checksum that disagreed. */
|
||||
readonly mismatchDetails: readonly string[];
|
||||
}
|
||||
|
||||
const checksumDescription = ({
|
||||
representation,
|
||||
algorithm,
|
||||
declared,
|
||||
observed,
|
||||
}: ArtifactChecksumObservation): string =>
|
||||
`Declared ${representation} ${algorithm} ${declared} disagrees with observed ${observed}.`;
|
||||
|
||||
const entryIntegrityFailure = (
|
||||
entry: ArtifactEntry,
|
||||
logicalPath: string,
|
||||
observed: {
|
||||
readonly sha256: string;
|
||||
readonly checksumMismatches: readonly ArtifactChecksumObservation[];
|
||||
},
|
||||
): ArtifactReaderFailure => {
|
||||
const mismatchDetails = observed.checksumMismatches.map(checksumDescription);
|
||||
return new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact integrity metadata disagrees with content: ${logicalPath}${mismatchDetails.length === 0 ? "" : ` (${mismatchDetails.join(" ")})`}`,
|
||||
undefined,
|
||||
{
|
||||
logicalPath,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: observed.sha256,
|
||||
unpacked: entry.unpacked,
|
||||
...(observed.checksumMismatches.length === 0
|
||||
? {}
|
||||
: { checksumMismatches: observed.checksumMismatches }),
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled(
|
||||
stack
|
||||
.filter(({ owned }) => owned)
|
||||
.map(async ({ reader, iterator }) => {
|
||||
await iterator.return?.();
|
||||
await reader.close();
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const digestArtifactEntry = async (
|
||||
@@ -198,47 +186,30 @@ const digestArtifactEntry = async (
|
||||
currentReader: ArtifactReader,
|
||||
entry: ArtifactEntry,
|
||||
logicalPath: string,
|
||||
): Promise<DigestedEntry | undefined> => {
|
||||
if (
|
||||
(entry.kind !== "file" && entry.kind !== "slice") ||
|
||||
entry.encrypted ||
|
||||
entry.contentUnavailable === true
|
||||
)
|
||||
): Promise<
|
||||
{ readonly node: ArtifactNode; readonly mismatched: boolean } | undefined
|
||||
> => {
|
||||
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
|
||||
return undefined;
|
||||
const checksum = entry.declaredChecksum;
|
||||
const digest = await hashReadable(
|
||||
await currentReader.open(entry, context.signal),
|
||||
context.signal,
|
||||
checksum?.algorithm,
|
||||
);
|
||||
const sha256Mismatch =
|
||||
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
|
||||
const checksumMismatch =
|
||||
checksum !== undefined && checksum.value !== digest.also;
|
||||
const storedMismatches = (
|
||||
currentReader.integrityObservations?.(entry) ?? []
|
||||
).filter(({ declared, observed }) => declared !== observed);
|
||||
const mismatched =
|
||||
sha256Mismatch || checksumMismatch || storedMismatches.length > 0;
|
||||
const checksumMismatches = [
|
||||
...(checksumMismatch
|
||||
? [
|
||||
{
|
||||
representation: "decoded" as const,
|
||||
algorithm: checksum.algorithm,
|
||||
declared: checksum.value,
|
||||
observed: digest.also ?? "unavailable",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...storedMismatches,
|
||||
];
|
||||
if (mismatched && context.integrity.mode === "fail")
|
||||
throw entryIntegrityFailure(entry, logicalPath, {
|
||||
sha256: digest.sha256,
|
||||
checksumMismatches,
|
||||
});
|
||||
if (sha256Mismatch && entry.declaredSha256 !== null)
|
||||
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
|
||||
if (mismatched && entry.declaredSha256 !== null) {
|
||||
if (context.integrity.mode === "fail")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Artifact integrity metadata disagrees with content: ${logicalPath}`,
|
||||
undefined,
|
||||
{
|
||||
logicalPath,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
calculatedSha256: digest.sha256,
|
||||
unpacked: entry.unpacked,
|
||||
},
|
||||
);
|
||||
context.pendingContradictions.push({
|
||||
logicalPath,
|
||||
declaredSha256: entry.declaredSha256,
|
||||
@@ -246,6 +217,7 @@ const digestArtifactEntry = async (
|
||||
entryKind: entry.kind,
|
||||
unpacked: entry.unpacked,
|
||||
});
|
||||
}
|
||||
const classified =
|
||||
entry.kind === "slice"
|
||||
? ({ kind: "universal-slice", format: "mach-o" } as const)
|
||||
@@ -265,10 +237,14 @@ const digestArtifactEntry = async (
|
||||
: [],
|
||||
}),
|
||||
mismatched,
|
||||
mismatchDetails: checksumMismatches.map(checksumDescription),
|
||||
};
|
||||
};
|
||||
|
||||
const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean =>
|
||||
entry.kind === "file" &&
|
||||
logicalPath.toLowerCase().endsWith(".asar") &&
|
||||
isAbsolute(entry.adapterKey);
|
||||
|
||||
const isUnavailableUnpackedEntry = (
|
||||
cause: unknown,
|
||||
entry: ArtifactEntry,
|
||||
|
||||
@@ -63,7 +63,7 @@ const artifact = <
|
||||
export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
artifact(
|
||||
"inspect_artifact",
|
||||
"Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Read-only DMG mounting is used automatically on supported macOS hosts. Flat installer packages (PKG) expand their gzip-cpio Scripts and Payload; pbzx payloads are recorded without expansion. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
|
||||
"Inspect an active archive or application package in one call. Returns the complete content-addressed artifact graph, source Evidence, observations, relationships, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
|
||||
artifactInventoryInputSchema,
|
||||
),
|
||||
artifact(
|
||||
|
||||
@@ -53,16 +53,6 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
declared_sha256: z.string().nullable(),
|
||||
calculated_sha256: z.string().nullable(),
|
||||
unpacked: z.boolean(),
|
||||
checksum_mismatches: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
representation: z.enum(["stored", "decoded"]),
|
||||
algorithm: z.string(),
|
||||
declared: z.string(),
|
||||
observed: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
|
||||
@@ -249,18 +249,6 @@ const artifactStateErrorDetails = (
|
||||
declared_sha256: error.artifactDetails.declaredSha256,
|
||||
calculated_sha256: error.artifactDetails.calculatedSha256,
|
||||
unpacked: error.artifactDetails.unpacked,
|
||||
...(error.artifactDetails.checksumMismatches === undefined
|
||||
? {}
|
||||
: {
|
||||
checksum_mismatches: error.artifactDetails.checksumMismatches.map(
|
||||
({ representation, algorithm, declared, observed }) => ({
|
||||
representation,
|
||||
algorithm,
|
||||
declared,
|
||||
observed,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return {
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
import { AnalysisError } from "./analysisErrorBase.js";
|
||||
|
||||
/** Declared and observed checksums of one consumed member representation. */
|
||||
export interface ArtifactChecksumObservation {
|
||||
readonly representation: "stored" | "decoded";
|
||||
readonly algorithm: string;
|
||||
readonly declared: string;
|
||||
readonly observed: string;
|
||||
}
|
||||
|
||||
/** Evidence identifying an artifact member's failed integrity constraints. */
|
||||
export interface ArtifactIntegrityFailureDetails {
|
||||
readonly logicalPath: string;
|
||||
readonly declaredSha256: string | null;
|
||||
readonly calculatedSha256: string | null;
|
||||
readonly unpacked: boolean;
|
||||
readonly checksumMismatches?: readonly ArtifactChecksumObservation[];
|
||||
}
|
||||
|
||||
/** Artifact inventory or extraction failed a typed safety boundary. */
|
||||
export class ArtifactOperationError extends AnalysisError {
|
||||
readonly _tag = "ArtifactOperationError";
|
||||
@@ -42,7 +25,12 @@ export class ArtifactOperationError extends AnalysisError {
|
||||
| "path"
|
||||
| "unavailable"
|
||||
| "io",
|
||||
readonly artifactDetails?: ArtifactIntegrityFailureDetails,
|
||||
readonly artifactDetails?: Readonly<{
|
||||
logicalPath: string;
|
||||
declaredSha256: string | null;
|
||||
calculatedSha256: string | null;
|
||||
unpacked: boolean;
|
||||
}>,
|
||||
/** The specific constraint that failed, such as the colliding path. */
|
||||
readonly detail?: string,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Observed signing-directory metadata; policy interpretation belongs to the adapter. */
|
||||
export const codeDirectorySchema = z.object({
|
||||
version: z.string().nullable(),
|
||||
flags: z
|
||||
.object({
|
||||
value: z.number().int().nonnegative(),
|
||||
names: z.array(z.string()),
|
||||
})
|
||||
.nullable(),
|
||||
code_slots: z.number().int().nonnegative().nullable(),
|
||||
special_slots: z.number().int().nonnegative().nullable(),
|
||||
location: z.string().nullable(),
|
||||
hash_type: z.string().nullable(),
|
||||
platform_identifier: z.number().int().nonnegative().nullable(),
|
||||
runtime_version: z.string().nullable(),
|
||||
executable_segment_flags: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
/** Observed sealed-resource summary. */
|
||||
export const sealedResourcesSchema = z.object({
|
||||
status: z.enum(["sealed", "none", "unknown"]),
|
||||
version: z.number().int().nullable(),
|
||||
rules: z.number().int().nullable(),
|
||||
files: z.number().int().nullable(),
|
||||
});
|
||||
|
||||
/** Local verification observation; unknown is distinct from a proven invalid signature. */
|
||||
export const signatureVerificationSchema = z.object({
|
||||
path: z.string(),
|
||||
status: z.enum(["valid", "invalid", "unsigned", "unknown"]),
|
||||
exit_code: z.number().int().nullable(),
|
||||
diagnostics: z.array(z.string()),
|
||||
validated_nested_code: z.array(z.string()),
|
||||
prepared_nested_code: z.array(z.string()).default([]),
|
||||
raw_stdout: z.string().default(""),
|
||||
raw_stderr: z.string().default(""),
|
||||
});
|
||||
|
||||
/** Local ticket presence and content identity, without a remote notarization claim. */
|
||||
export const stapledTicketSchema = z.object({
|
||||
status: z.enum(["present", "absent", "unreadable", "not-applicable"]),
|
||||
path: z.string().nullable(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
size: z.number().int().nonnegative().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
|
||||
const facetSchema = z.discriminatedUnion("facet", [
|
||||
z.object({
|
||||
facet: z.literal("library-validation"),
|
||||
state: z.enum(["enforced", "disabled", "not-enforced", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("dyld-environment-variables"),
|
||||
state: z.enum(["ignored", "honored", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("debugger-attach"),
|
||||
state: z.enum(["allowed", "blocked", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("executable-memory"),
|
||||
state: z.enum([
|
||||
"restricted",
|
||||
"jit-allowed",
|
||||
"unsigned-allowed",
|
||||
"protection-disabled",
|
||||
"unrestricted",
|
||||
"unknown",
|
||||
]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("app-sandbox"),
|
||||
state: z.enum(["sandboxed", "not-sandboxed", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Shared result semantics; adapters determine each facet's policy and evidence. */
|
||||
export const securityFacetsSchema = z.array(facetSchema);
|
||||
export type CodeDirectory = z.infer<typeof codeDirectorySchema>;
|
||||
export type SecurityFacet = z.infer<typeof facetSchema>;
|
||||
@@ -1,4 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
codeDirectorySchema,
|
||||
sealedResourcesSchema,
|
||||
securityFacetsSchema,
|
||||
signatureVerificationSchema,
|
||||
stapledTicketSchema,
|
||||
} from "./codeSigningPosture.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
|
||||
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
|
||||
@@ -113,6 +120,11 @@ export const inspectSignatureSchema = z.object({
|
||||
entitlements: jsonValueSchema.nullable(),
|
||||
timestamp: z.string().nullable(),
|
||||
hardened_runtime: z.boolean().nullable(),
|
||||
code_directory: codeDirectorySchema.nullable(),
|
||||
sealed_resources: sealedResourcesSchema,
|
||||
verification: signatureVerificationSchema.nullable(),
|
||||
stapled_ticket: stapledTicketSchema,
|
||||
security_facets: securityFacetsSchema,
|
||||
provenance: z.array(nativeCommandInvocationSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
codeDirectoryFlagNames,
|
||||
deriveSecurityFacets,
|
||||
} from "./CodeSigningPolicy.js";
|
||||
import type { CodeDirectory } from "../domain/native/codeSigningPosture.js";
|
||||
|
||||
const directory = (
|
||||
flags: number | null,
|
||||
platform: number | null = null,
|
||||
): CodeDirectory => ({
|
||||
version: "20500",
|
||||
flags:
|
||||
flags === null
|
||||
? null
|
||||
: { value: flags, names: codeDirectoryFlagNames(flags) },
|
||||
code_slots: 1,
|
||||
special_slots: 7,
|
||||
location: "embedded",
|
||||
hash_type: "sha256",
|
||||
platform_identifier: platform,
|
||||
runtime_version: null,
|
||||
executable_segment_flags: 1,
|
||||
});
|
||||
|
||||
type PostureInput = Parameters<typeof deriveSecurityFacets>[0];
|
||||
|
||||
const derive = (
|
||||
input: Omit<PostureInput, "mixedSlices" | "appleOrigin"> &
|
||||
Partial<PostureInput>,
|
||||
) => deriveSecurityFacets({ mixedSlices: false, appleOrigin: false, ...input });
|
||||
|
||||
const states = (facets: ReturnType<typeof deriveSecurityFacets>) =>
|
||||
Object.fromEntries(facets.map(({ facet, state }) => [facet, state]));
|
||||
|
||||
const RUNTIME = 0x10000;
|
||||
|
||||
describe("CodeDirectory flags", () => {
|
||||
it("names known bits and keeps unknown bits numeric", () => {
|
||||
expect(codeDirectoryFlagNames(0x12a02)).toEqual([
|
||||
"adhoc",
|
||||
"kill",
|
||||
"restrict",
|
||||
"library-validation",
|
||||
"runtime",
|
||||
]);
|
||||
expect(codeDirectoryFlagNames(0x40000 | 0x2)).toEqual(["adhoc", "0x40000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived security facets", () => {
|
||||
it("does not apply platform policy for a reported zero platform byte", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2, 0),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "not-enforced",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
});
|
||||
});
|
||||
it("treats unsigned and plain ad hoc code as unrestricted", () => {
|
||||
for (const signed of [false, true])
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed,
|
||||
codeDirectory: signed ? directory(0x2) : null,
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "not-enforced",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "unrestricted",
|
||||
"app-sandbox": "not-sandboxed",
|
||||
});
|
||||
});
|
||||
|
||||
it("restricts hardened-runtime code unless entitlements relax it", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: { "com.apple.security.app-sandbox": true },
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
"executable-memory": "restricted",
|
||||
"app-sandbox": "sandboxed",
|
||||
});
|
||||
const relaxed = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: {
|
||||
"com.apple.security.cs.disable-library-validation": true,
|
||||
"com.apple.security.cs.allow-dyld-environment-variables": true,
|
||||
"com.apple.security.get-task-allow": true,
|
||||
"com.apple.security.cs.allow-jit": true,
|
||||
},
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(relaxed)).toMatchObject({
|
||||
"library-validation": "disabled",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "jit-allowed",
|
||||
});
|
||||
expect(relaxed[0]?.evidence).toEqual([
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets explicit flags and platform identity override entitlements", () => {
|
||||
const flagged = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | 0x800 | RUNTIME),
|
||||
entitlements: {
|
||||
"com.apple.security.cs.disable-library-validation": true,
|
||||
},
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(flagged)).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
});
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("security facets that need more than one CodeDirectory", () => {
|
||||
it("applies platform policy only to Apple-signed platform identifiers", () => {
|
||||
const crafted = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(crafted)).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
});
|
||||
expect(crafted[0]?.evidence).toEqual([
|
||||
"platform identifier",
|
||||
"Apple origin not verified",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every facet unknown when slices differ in signing state", () => {
|
||||
const mixed = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: false,
|
||||
appleOrigin: true,
|
||||
mixedSlices: true,
|
||||
});
|
||||
expect(new Set(mixed.map(({ state }) => state))).toEqual(
|
||||
new Set(["unknown"]),
|
||||
);
|
||||
expect(mixed[0]?.evidence).toEqual([
|
||||
"architecture slices differ in signing state",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every facet unknown when verification failed", () => {
|
||||
const invalid = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
signatureInvalid: true,
|
||||
});
|
||||
expect(new Set(invalid.map(({ state }) => state))).toEqual(
|
||||
new Set(["unknown"]),
|
||||
);
|
||||
expect(invalid[0]?.evidence).toEqual(["signature verification failed"]);
|
||||
});
|
||||
|
||||
it("keeps facets unknown when flags or entitlements are unavailable", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: null,
|
||||
entitlementsKnown: false,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
"executable-memory": "unknown",
|
||||
"app-sandbox": "unknown",
|
||||
});
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(null),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
"executable-memory": "unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
CodeDirectory,
|
||||
SecurityFacet,
|
||||
} from "../domain/native/codeSigningPosture.js";
|
||||
|
||||
/** `SecCodeSignatureFlags` bits that codesign reports in a CodeDirectory. */
|
||||
const CODE_DIRECTORY_FLAGS: readonly (readonly [number, string])[] = [
|
||||
[0x1, "host"],
|
||||
[0x2, "adhoc"],
|
||||
[0x100, "hard"],
|
||||
[0x200, "kill"],
|
||||
[0x400, "expires"],
|
||||
[0x800, "restrict"],
|
||||
[0x1000, "enforcement"],
|
||||
[0x2000, "library-validation"],
|
||||
[0x10000, "runtime"],
|
||||
[0x20000, "linker-signed"],
|
||||
];
|
||||
|
||||
/** Decode CodeDirectory flag bits; unknown bits keep their hexadecimal value. */
|
||||
export const codeDirectoryFlagNames = (value: number): string[] => {
|
||||
const flags = value >>> 0;
|
||||
const names = CODE_DIRECTORY_FLAGS.flatMap(([bit, name]) =>
|
||||
(flags & bit) === 0 ? [] : [name],
|
||||
);
|
||||
const known = CODE_DIRECTORY_FLAGS.reduce((total, [bit]) => total | bit, 0);
|
||||
for (let index = 0; index < 32; index++)
|
||||
if (((flags >>> index) & 1) === 1 && ((known >>> index) & 1) === 0)
|
||||
names.push(`0x${(2 ** index).toString(16)}`);
|
||||
return names;
|
||||
};
|
||||
|
||||
const entitlement = (
|
||||
entitlements: JsonValue | null,
|
||||
key: string,
|
||||
): boolean | undefined => {
|
||||
if (
|
||||
entitlements === null ||
|
||||
typeof entitlements !== "object" ||
|
||||
Array.isArray(entitlements)
|
||||
)
|
||||
return undefined;
|
||||
const value = entitlements[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
};
|
||||
|
||||
interface PostureInput {
|
||||
readonly signed: boolean;
|
||||
readonly codeDirectory: CodeDirectory | null;
|
||||
readonly entitlements: JsonValue | null;
|
||||
/** False when mixed or unreadable signing states hide the aggregate entitlements. */
|
||||
readonly entitlementsKnown: boolean;
|
||||
/** Architecture slices differ in signing state, so no one CodeDirectory describes the process. */
|
||||
readonly mixedSlices: boolean;
|
||||
/**
|
||||
* The signature satisfies `anchor apple`. Only then does a CodeDirectory
|
||||
* platform identifier mean an Apple platform binary.
|
||||
*/
|
||||
readonly appleOrigin: boolean;
|
||||
/**
|
||||
* The local `--deep --strict` verification failed for the opened code.
|
||||
* Flag bits from an unauthenticated CodeDirectory must not decide facets.
|
||||
*/
|
||||
readonly signatureInvalid?: boolean;
|
||||
}
|
||||
|
||||
/** Whether the CodeDirectory claims a platform binary, and whether Apple signed it. */
|
||||
const platformBinary = (
|
||||
input: PostureInput,
|
||||
): "apple" | "unverified" | "none" =>
|
||||
(input.codeDirectory?.platform_identifier ?? 0) === 0
|
||||
? "none"
|
||||
: input.appleOrigin
|
||||
? "apple"
|
||||
: "unverified";
|
||||
|
||||
const UNVERIFIED_PLATFORM = [
|
||||
"platform identifier",
|
||||
"Apple origin not verified",
|
||||
] as const;
|
||||
|
||||
const has = (input: PostureInput, flag: string): boolean =>
|
||||
input.codeDirectory?.flags?.names.includes(flag) === true;
|
||||
|
||||
const libraryValidation = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "library-validation", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"With library validation, the process loads only libraries signed by Apple or by the same team; without it, any library dyld finds can load.";
|
||||
if (!input.signed)
|
||||
return {
|
||||
...base,
|
||||
state: "not-enforced",
|
||||
evidence: ["unsigned"],
|
||||
explanation,
|
||||
};
|
||||
if (input.codeDirectory === null || input.codeDirectory.flags === null)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (has(input, "library-validation"))
|
||||
return {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["CodeDirectory flag library-validation"],
|
||||
explanation,
|
||||
};
|
||||
if (!has(input, "runtime"))
|
||||
return {
|
||||
...base,
|
||||
state: "not-enforced",
|
||||
evidence: ["no hardened runtime or library-validation flag"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(
|
||||
input.entitlements,
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
) === true
|
||||
? {
|
||||
...base,
|
||||
state: "disabled",
|
||||
evidence: [
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const dyldEnvironment = (input: PostureInput): SecurityFacet => {
|
||||
const base = {
|
||||
facet: "dyld-environment-variables",
|
||||
basis: "derived",
|
||||
} as const;
|
||||
const explanation =
|
||||
"Whether dyld honors DYLD_* variables such as DYLD_INSERT_LIBRARIES at launch. setuid/setgid bits, a __RESTRICT segment, and system integrity policy can also restrict them and are not evaluated here.";
|
||||
if (has(input, "restrict"))
|
||||
return {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["CodeDirectory flag restrict"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (!has(input, "runtime"))
|
||||
return input.signed && (input.codeDirectory?.flags ?? null) === null
|
||||
? {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "honored",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(
|
||||
input.entitlements,
|
||||
"com.apple.security.cs.allow-dyld-environment-variables",
|
||||
) === true
|
||||
? {
|
||||
...base,
|
||||
state: "honored",
|
||||
evidence: [
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.allow-dyld-environment-variables",
|
||||
],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const debuggerAttach = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "debugger-attach", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Whether a debugger running as the same user can obtain the task port. Developer Tools authorization and system integrity policy still apply.";
|
||||
if (
|
||||
input.entitlementsKnown &&
|
||||
entitlement(input.entitlements, "com.apple.security.get-task-allow") ===
|
||||
true
|
||||
)
|
||||
return {
|
||||
...base,
|
||||
state: "allowed",
|
||||
evidence: ["com.apple.security.get-task-allow"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "blocked",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (has(input, "runtime"))
|
||||
return input.entitlementsKnown
|
||||
? {
|
||||
...base,
|
||||
state: "blocked",
|
||||
evidence: ["hardened runtime without get-task-allow"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
if (input.signed && (input.codeDirectory?.flags ?? null) === null)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
state: "allowed",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const executableMemory = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "executable-memory", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Hardened-runtime limits on writable or unsigned executable memory, relaxed by code-signing entitlements.";
|
||||
if (!has(input, "runtime"))
|
||||
return input.signed && (input.codeDirectory?.flags ?? null) === null
|
||||
? {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unrestricted",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
for (const [key, state] of [
|
||||
[
|
||||
"com.apple.security.cs.disable-executable-page-protection",
|
||||
"protection-disabled",
|
||||
],
|
||||
[
|
||||
"com.apple.security.cs.allow-unsigned-executable-memory",
|
||||
"unsigned-allowed",
|
||||
],
|
||||
["com.apple.security.cs.allow-jit", "jit-allowed"],
|
||||
] as const)
|
||||
if (entitlement(input.entitlements, key) === true)
|
||||
return {
|
||||
...base,
|
||||
state,
|
||||
evidence: ["hardened runtime", key],
|
||||
explanation,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
state: "restricted",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const appSandbox = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "app-sandbox", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Whether the App Sandbox entitlement confines the process.";
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(input.entitlements, "com.apple.security.app-sandbox") ===
|
||||
true
|
||||
? {
|
||||
...base,
|
||||
state: "sandboxed",
|
||||
evidence: ["com.apple.security.app-sandbox"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "not-sandboxed",
|
||||
evidence: ["no com.apple.security.app-sandbox entitlement"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
/** Derive launch-time security facets from observed flags and entitlements. */
|
||||
export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => {
|
||||
const facets = [
|
||||
libraryValidation(input),
|
||||
dyldEnvironment(input),
|
||||
debuggerAttach(input),
|
||||
executableMemory(input),
|
||||
appSandbox(input),
|
||||
];
|
||||
// An invalid main signature leaves flag bits unauthenticated; keep the raw
|
||||
// CodeDirectory fields but report every facet as unknown.
|
||||
if (input.signatureInvalid === true)
|
||||
return facets.map((facet) => ({
|
||||
...facet,
|
||||
state: "unknown" as const,
|
||||
evidence: ["signature verification failed"],
|
||||
}));
|
||||
// Each slice runs under its own signature; the inspected one does not
|
||||
// describe an unsigned slice of the same file.
|
||||
return input.mixedSlices
|
||||
? facets.map((facet) => ({
|
||||
...facet,
|
||||
state: "unknown" as const,
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
}))
|
||||
: facets;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { InspectSignature } from "../domain/native/nativeInspection.js";
|
||||
import type { NativeCommandCapture } from "./CommandRunner.js";
|
||||
|
||||
/** Strip the diagnostic's echoed pathname for classification, keeping the raw line elsewhere. */
|
||||
export const codesignReason = (line: string, path: string): string => {
|
||||
if (line.startsWith(`${path}: `)) return line.slice(path.length + 2);
|
||||
if (line.startsWith("/") || line.startsWith(`${path}/`)) {
|
||||
const delimiter = line.lastIndexOf(": ");
|
||||
return delimiter >= 0 ? line.slice(delimiter + 2) : "";
|
||||
}
|
||||
return line;
|
||||
};
|
||||
|
||||
/** Remove the complete selected path before splitting lines, including embedded LF bytes. */
|
||||
export const codesignReasons = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): string[] => {
|
||||
const observed = `${capture.stderr}\n${capture.stdout}`;
|
||||
const text =
|
||||
path.length === 0 ? observed : observed.split(path).join("$SELECTED_PATH");
|
||||
const lines = text.split("\n");
|
||||
const progress = lines.some((line) =>
|
||||
/^--(?:prepared|validated):/u.test(line),
|
||||
);
|
||||
return (
|
||||
lines
|
||||
.filter((line) => !/^--(?:prepared|validated):/u.test(line))
|
||||
// Unprefixed continuation text can belong to an unescaped nested filename.
|
||||
// Keep it as raw evidence, but do not turn it into a verification reason.
|
||||
.filter(
|
||||
(line) =>
|
||||
!progress ||
|
||||
line.startsWith("$SELECTED_PATH: ") ||
|
||||
line.startsWith("$SELECTED_PATH/"),
|
||||
)
|
||||
.map((line) => codesignReason(line, "$SELECTED_PATH"))
|
||||
);
|
||||
};
|
||||
|
||||
/** Classify only explicit diagnostic reasons; nonzero exit alone proves no invalidity. */
|
||||
const operationalReason = (reason: string): boolean =>
|
||||
/^(?:permission denied|operation not permitted|EACCES\b|EPERM\b|I\/O error|input\/output error)/iu.test(
|
||||
reason,
|
||||
);
|
||||
|
||||
/**
|
||||
* Definitive codesign rejections. A nonzero exit whose text is not in this
|
||||
* list, and is not an operational failure, stays `unknown` so an unrecognized
|
||||
* diagnostic is not reported as a broken signature.
|
||||
*/
|
||||
const INVALID_SIGNATURE_REASONS = [
|
||||
"a resource envelope is obsolete",
|
||||
"a sealed resource is missing or invalid",
|
||||
"bundle format unrecognized, invalid, or unsuitable",
|
||||
"code or signature modified",
|
||||
"file added:",
|
||||
"file modified:",
|
||||
"invalid or unsupported format",
|
||||
"invalid signature",
|
||||
"main executable failed strict validation",
|
||||
"resource envelope is obsolete",
|
||||
"resource fork, Finder information, or similar detritus not allowed",
|
||||
"signature is invalid",
|
||||
"unsealed contents present",
|
||||
] as const;
|
||||
|
||||
const invalidSignatureReason = (reason: string): boolean => {
|
||||
const text = reason.toLowerCase();
|
||||
return INVALID_SIGNATURE_REASONS.some((phrase) =>
|
||||
text.startsWith(phrase.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
/** Operational diagnostics can coexist with an independently proven invalid component. */
|
||||
export const codesignOperationalFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): boolean => codesignReasons(capture, path).some(operationalReason);
|
||||
|
||||
const verificationStatus = (
|
||||
capture: NativeCommandCapture,
|
||||
reasons: readonly string[],
|
||||
unsigned: boolean,
|
||||
): NonNullable<InspectSignature["verification"]>["status"] => {
|
||||
if (capture.exitCode === 0) return "valid";
|
||||
const unsignedReason = reasons.some((reason) =>
|
||||
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
|
||||
reason,
|
||||
),
|
||||
);
|
||||
if (reasons.some(invalidSignatureReason)) return "invalid";
|
||||
if (unsignedReason && !unsigned) return "invalid";
|
||||
if (reasons.some(operationalReason)) return "unknown";
|
||||
if (unsignedReason) return "unsigned";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
/** Project codesign verification without changing pathname bytes or discarding diagnostics. */
|
||||
export const signatureVerification = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
unsigned: boolean,
|
||||
progressCompleteness: "complete" | "ambiguous" = "complete",
|
||||
): NonNullable<InspectSignature["verification"]> => {
|
||||
const lines = `${capture.stderr}\n${capture.stdout}`
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
const diagnostics = lines.filter(
|
||||
(line) => !/^--(?:prepared|validated):/u.test(line),
|
||||
);
|
||||
return {
|
||||
path,
|
||||
status:
|
||||
progressCompleteness === "ambiguous" && capture.exitCode !== 0
|
||||
? "unknown"
|
||||
: verificationStatus(capture, codesignReasons(capture, path), unsigned),
|
||||
exit_code: capture.exitCode,
|
||||
diagnostics,
|
||||
prepared_nested_code: [
|
||||
...new Set(
|
||||
lines.flatMap((line) =>
|
||||
line.startsWith("--prepared:")
|
||||
? [line.slice("--prepared:".length)]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
raw_stdout: capture.stdout,
|
||||
raw_stderr: capture.stderr,
|
||||
validated_nested_code: [
|
||||
...new Set(
|
||||
lines.flatMap((line) =>
|
||||
line.startsWith("--validated:")
|
||||
? [line.slice("--validated:".length)]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { observeNativeUi } from "./NativeUiObservation.js";
|
||||
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
|
||||
import { observeNativeCalls } from "./NativeCallObservation.js";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { open, realpath, stat } from "node:fs/promises";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type NativeToolName,
|
||||
} from "../contracts/native/nativeToolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -36,14 +35,9 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../domain/propertyListKeys.js";
|
||||
import {
|
||||
demangleSwiftSchema,
|
||||
inspectPlistSchema,
|
||||
inspectSignatureSchema,
|
||||
listArchitecturesSchema,
|
||||
type NativeCommandInvocation,
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
@@ -54,7 +48,8 @@ import {
|
||||
type NativeCommandCapture,
|
||||
type NativeCommandRunner,
|
||||
} from "./CommandRunner.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { inspectNativeSignature } from "./NativeSignatureInspection.js";
|
||||
import type { NativeObservation } from "./NativeObservation.js";
|
||||
import { parseDemangledSymbols } from "./parsers/demangle.js";
|
||||
import { parseLipoArchitectures } from "./parsers/lipo.js";
|
||||
import {
|
||||
@@ -208,6 +203,10 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
)
|
||||
: observation;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
// A helper that observed the abort escapes as a DOMException.
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisOutputError(operation, "Native output parsing failed", {
|
||||
cause,
|
||||
@@ -318,127 +317,16 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
async #inspectSignature(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const display = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "--verbose=4", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!display.ok) return display;
|
||||
const unsigned = isNonzeroUnsignedObservation(display.value);
|
||||
const displayFailure = codeSignCaptureFailure(display.value);
|
||||
if (displayFailure !== null) return err(displayFailure);
|
||||
const requirements = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "-r-", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!requirements.ok) return requirements;
|
||||
const requirementsFailure = codeSignCaptureFailure(requirements.value);
|
||||
if (requirementsFailure !== null) return err(requirementsFailure);
|
||||
const entitlements = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "--entitlements", ":-", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
const entitlementsFailure = codeSignCaptureFailure(entitlements.value);
|
||||
if (entitlementsFailure !== null) return err(entitlementsFailure);
|
||||
// codesign echoes the resolved executable path in its diagnostics.
|
||||
const parsed = parseCodeSignature(display.value.stderr, unsigned, [
|
||||
this.target.path,
|
||||
...(await canonicalPath(this.target.path)),
|
||||
]);
|
||||
// The requirement is printed to stdout; stderr echoes the path.
|
||||
const requirementText =
|
||||
/designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null;
|
||||
// Entitlements XML is printed to stdout; stderr echoes the path.
|
||||
const entitlementValue = parseEntitlements(entitlements.value.stdout);
|
||||
const captures = [display.value, requirements.value, entitlements.value];
|
||||
const limitations = [
|
||||
...parsed.limitations,
|
||||
...(entitlementValue.omittedPrototypeKeys === 0
|
||||
? []
|
||||
: [
|
||||
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
|
||||
]),
|
||||
];
|
||||
const mixedSigning =
|
||||
!unsigned &&
|
||||
(isNonzeroUnsignedObservation(requirements.value) ||
|
||||
isNonzeroUnsignedObservation(entitlements.value));
|
||||
if (mixedSigning) {
|
||||
const slices = await this.#inspectMixedSignatureSlices(
|
||||
parsed.format,
|
||||
requirements.value.exitCode !== 0,
|
||||
entitlements.value.exitCode !== 0,
|
||||
signal,
|
||||
);
|
||||
if (!slices.ok) return slices;
|
||||
captures.push(...slices.value.captures);
|
||||
limitations.push(...slices.value.limitations);
|
||||
}
|
||||
const provenance = captures.map((capture) =>
|
||||
invocation(capture, this.target.path),
|
||||
);
|
||||
const result = inspectSignatureSchema.parse({
|
||||
...parsed,
|
||||
designated_requirement: requirementText,
|
||||
entitlements: entitlementValue.value,
|
||||
provenance,
|
||||
limitations,
|
||||
return inspectNativeSignature({
|
||||
target: this.target,
|
||||
capture: (argv, commandSignal) =>
|
||||
this.#run("inspect_signature", "codesign", argv, {
|
||||
signal: commandSignal,
|
||||
acceptNonZero: true,
|
||||
}),
|
||||
invocation: (capture) => invocation(capture, this.target.path),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
return ok({
|
||||
result: jsonValueSchema.parse(result),
|
||||
provenance,
|
||||
limitations: result.limitations,
|
||||
locations: [],
|
||||
});
|
||||
}
|
||||
|
||||
async #inspectMixedSignatureSlices(
|
||||
format: string | null,
|
||||
requirementsUnavailable: boolean,
|
||||
entitlementsUnavailable: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<MixedSignatureSlices, AnalysisError>> {
|
||||
const captures: NativeCommandCapture[] = [];
|
||||
const unsigned: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
for (const architecture of codeSignArchitectures(format, this.target)) {
|
||||
const slice = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "-a", architecture, "--verbose=4", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!slice.ok) return slice;
|
||||
captures.push(slice.value);
|
||||
if (slice.value.exitCode === 0) continue;
|
||||
if (isNonzeroUnsignedObservation(slice.value))
|
||||
unsigned.push(architecture);
|
||||
else unclassified.push(architecture);
|
||||
}
|
||||
const limitations = [
|
||||
unsigned.length > 0
|
||||
? `Unsigned Mach-O slices: ${unsigned.join(", ")}.`
|
||||
: "One or more Mach-O slices are unsigned, but their architectures could not be classified.",
|
||||
];
|
||||
if (unclassified.length > 0)
|
||||
limitations.push(
|
||||
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
|
||||
);
|
||||
if (requirementsUnavailable)
|
||||
limitations.push(
|
||||
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
if (entitlementsUnavailable)
|
||||
limitations.push(
|
||||
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
return ok({ captures, limitations });
|
||||
}
|
||||
|
||||
async #inspectPlist(
|
||||
@@ -563,18 +451,6 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
}
|
||||
}
|
||||
|
||||
interface NativeObservation {
|
||||
readonly result: JsonValue;
|
||||
readonly provenance: readonly NativeCommandInvocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
}
|
||||
|
||||
interface MixedSignatureSlices {
|
||||
readonly captures: readonly NativeCommandCapture[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const isNativeOperation = (
|
||||
operation: AnalysisOperation,
|
||||
): operation is NativeToolName =>
|
||||
@@ -595,62 +471,6 @@ const translateCommandFailure = (
|
||||
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
|
||||
};
|
||||
|
||||
const translateCodeSignExitFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): AnalysisError =>
|
||||
translateCommandFailure(
|
||||
"inspect_signature",
|
||||
new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode),
|
||||
);
|
||||
|
||||
const commandOutput = (capture: NativeCommandCapture): string =>
|
||||
`${capture.stdout}\n${capture.stderr}`;
|
||||
|
||||
/**
|
||||
* A signed artifact exits zero; its diagnostics can still contain this text
|
||||
* through the echoed path or identifier.
|
||||
*/
|
||||
const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean =>
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
/not signed at all|code object is not signed/iu.test(commandOutput(capture));
|
||||
|
||||
const canonicalPath = async (path: string): Promise<string[]> => {
|
||||
try {
|
||||
return [await realpath(path)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const codeSignCaptureFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): AnalysisError | null =>
|
||||
capture.exitCode === 0 || isNonzeroUnsignedObservation(capture)
|
||||
? null
|
||||
: translateCodeSignExitFailure(capture);
|
||||
|
||||
const codeSignArchitectures = (
|
||||
format: string | null,
|
||||
target: BinaryTarget,
|
||||
): string[] => {
|
||||
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
|
||||
format ?? "",
|
||||
)?.[1];
|
||||
if (universal !== undefined) {
|
||||
const architectures = universal
|
||||
.split(/\s+/u)
|
||||
.filter((architecture) =>
|
||||
/^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture),
|
||||
);
|
||||
if (architectures.length > 0) return [...new Set(architectures)];
|
||||
}
|
||||
if (target.kind !== "executable" || target.format !== "mach-o") return [];
|
||||
return target.availableArchitectures.map((architecture) =>
|
||||
architecture === "x86" ? "i386" : architecture,
|
||||
);
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
capture: NativeCommandCapture,
|
||||
artifactPath: string,
|
||||
@@ -671,18 +491,6 @@ const invocation = (
|
||||
stderr_bytes: capture.stderrBytes,
|
||||
});
|
||||
|
||||
const parseEntitlements = (
|
||||
output: string,
|
||||
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
|
||||
const start = output.indexOf("<?xml");
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
|
||||
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
|
||||
output.slice(start, end + "</plist>".length),
|
||||
);
|
||||
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a plist that cannot be decoded as the caller's selection, or as the
|
||||
* target's missing default, rather than as a failed tool run.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
|
||||
|
||||
it("observes cancellation between nested filesystem lookups", async () => {
|
||||
const controller = new AbortController();
|
||||
const paths: string[] = [];
|
||||
await expect(
|
||||
inspectNestedCodePaths(
|
||||
{
|
||||
validated_nested_code: ["/app/first", "/app/second"],
|
||||
},
|
||||
controller.signal,
|
||||
{
|
||||
lookup: async (path) => {
|
||||
paths.push(path);
|
||||
controller.abort();
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(paths).toEqual(["/app/first"]);
|
||||
});
|
||||
|
||||
it("checks prepared-only fragments with the same completeness rules", async () => {
|
||||
const warnings = await inspectNestedCodePaths(
|
||||
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("not found"), { code: "ENOENT" });
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
);
|
||||
expect(warnings.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
'prepared nested code at "/app/odd", which does not exist',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["EACCES", "EPERM", "EIO", "ESTALE"])(
|
||||
"keeps observed records when path lookup fails with %s",
|
||||
async (code) => {
|
||||
const result = await inspectNestedCodePaths(
|
||||
{
|
||||
prepared_nested_code: ["/app/first"],
|
||||
validated_nested_code: ["/app/second"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("lookup failed"), { code });
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
);
|
||||
expect(result.limitations).toHaveLength(2);
|
||||
expect(result.limitations.every((message) => message.includes(code))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.progressAmbiguous).toBe(true);
|
||||
},
|
||||
);
|
||||
it("detects newline ambiguity even when the reported first fragment does not exist", async () => {
|
||||
const result = await inspectNestedCodePaths(
|
||||
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
},
|
||||
names: async () => ["odd\n/app: invalid signature"],
|
||||
},
|
||||
);
|
||||
expect(result.progressAmbiguous).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { dirname, basename } from "node:path";
|
||||
|
||||
/** Filesystem seam for path-completeness checks and cancellation regressions. */
|
||||
export interface NativeNestedPathView {
|
||||
lookup(path: string): Promise<void>;
|
||||
names(directory: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
const FILE_SYSTEM: NativeNestedPathView = {
|
||||
lookup: async (path) => {
|
||||
await lstat(path);
|
||||
},
|
||||
names: (path) => readdir(path),
|
||||
};
|
||||
|
||||
const errorCode = (cause: unknown) =>
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
|
||||
/** Completeness of newline-delimited progress records, distinct from verification. */
|
||||
export interface NestedCodePathInspection {
|
||||
readonly limitations: readonly string[];
|
||||
readonly progressAmbiguous: boolean;
|
||||
}
|
||||
|
||||
/** Preserve reported paths and operational errors, and identify ambiguous progress. */
|
||||
export const inspectNestedCodePaths = async (
|
||||
verification: {
|
||||
readonly validated_nested_code: readonly string[];
|
||||
readonly prepared_nested_code?: readonly string[];
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
view: NativeNestedPathView = FILE_SYSTEM,
|
||||
): Promise<NestedCodePathInspection> => {
|
||||
signal?.throwIfAborted();
|
||||
const limitations: string[] = [];
|
||||
const directories = new Map<string, Promise<ReadonlySet<string>>>();
|
||||
let progressAmbiguous = false;
|
||||
const records = new Map<string, "prepared" | "validated">();
|
||||
for (const path of verification.prepared_nested_code ?? [])
|
||||
records.set(path, "prepared");
|
||||
for (const path of verification.validated_nested_code)
|
||||
records.set(path, "validated");
|
||||
for (const [path, record] of records) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await view.lookup(path);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const code = errorCode(cause);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: permission denied (${code}); the reported path may be a fragment.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (code === "ENOENT" || code === "ENOTDIR")
|
||||
limitations.push(
|
||||
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which does not exist; structured nested-code records may hold fragments of a path containing a newline.`,
|
||||
);
|
||||
else {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: ${cause instanceof Error ? cause.message : String(cause)} (${String(code ?? "unknown")}); the reported path may be a fragment.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const parent = dirname(path);
|
||||
let names = directories.get(parent);
|
||||
if (names === undefined) {
|
||||
names = view
|
||||
.names(parent)
|
||||
.then(
|
||||
(entries) =>
|
||||
new Set(
|
||||
entries
|
||||
.filter((name) => name.includes("\n"))
|
||||
.map((name) => name.slice(0, name.indexOf("\n"))),
|
||||
),
|
||||
);
|
||||
directories.set(parent, names);
|
||||
}
|
||||
try {
|
||||
const prefixes = await names;
|
||||
const ambiguous = prefixes.has(basename(path));
|
||||
signal?.throwIfAborted();
|
||||
if (ambiguous) {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which could be the first line of a nested path containing a newline; structured nested-code records are ambiguous and should not be trusted as complete.`,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)} from parent directory ${JSON.stringify(parent)}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { limitations, progressAmbiguous };
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { NativeCommandInvocation } from "../domain/native/nativeInspection.js";
|
||||
|
||||
/** Provider observation before shared execution/Evidence projection. */
|
||||
export interface NativeObservation {
|
||||
readonly result: JsonValue;
|
||||
readonly provenance: readonly NativeCommandInvocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Parse the XML entitlement document within codesign output, retaining omission evidence. */
|
||||
export const parseSignatureEntitlements = (
|
||||
output: string,
|
||||
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
|
||||
const start = output.indexOf("<?xml");
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
|
||||
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
|
||||
output.slice(start, end + "</plist>".length),
|
||||
);
|
||||
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
inspectSignatureSchema,
|
||||
type NativeCommandInvocation,
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
import { deriveSecurityFacets } from "./CodeSigningPolicy.js";
|
||||
import {
|
||||
bindSignatureTarget,
|
||||
verifySignatureTarget,
|
||||
} from "./SignatureTargetBinding.js";
|
||||
import { omittedPrototypeKeysLimitation } from "../domain/propertyListKeys.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
NativeCommandFailure,
|
||||
type NativeCommandCapture,
|
||||
} from "./CommandRunner.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
import type { NativeObservation } from "./NativeObservation.js";
|
||||
import {
|
||||
signatureVerification,
|
||||
codesignOperationalFailure,
|
||||
codesignReasons,
|
||||
} from "./CodesignVerification.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
|
||||
import {
|
||||
inspectSignatureSlices,
|
||||
unsignedSignatureCapture,
|
||||
type SignatureCapture,
|
||||
} from "./NativeSignatureProbes.js";
|
||||
import {
|
||||
SIGNATURE_POSTURE_LIMITATIONS,
|
||||
signedCodePath,
|
||||
stapledTicket,
|
||||
} from "./NativeSignaturePosture.js";
|
||||
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
|
||||
|
||||
interface SignatureDisplays {
|
||||
readonly display: NativeCommandCapture;
|
||||
readonly requirements: NativeCommandCapture;
|
||||
readonly entitlements: NativeCommandCapture;
|
||||
}
|
||||
|
||||
/** Display failures and unsigned observations use the same reason parser as verification. */
|
||||
const signatureDisplays = async (
|
||||
path: string,
|
||||
capture: SignatureCapture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureDisplays, AnalysisError>> => {
|
||||
const display = await capture(["-d", "--verbose=4", path], signal);
|
||||
if (!display.ok) return display;
|
||||
const requirements = await capture(["-d", "-r-", path], signal);
|
||||
if (!requirements.ok) return requirements;
|
||||
const entitlements = await capture(
|
||||
["-d", "--entitlements", ":-", path],
|
||||
signal,
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
for (const result of [
|
||||
display.value,
|
||||
requirements.value,
|
||||
entitlements.value,
|
||||
]) {
|
||||
if (result.exitCode !== 0 && !unsignedSignatureCapture(result, path))
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
"inspect_signature",
|
||||
{
|
||||
diagnostics: {
|
||||
path,
|
||||
tool: result.tool,
|
||||
arguments: [...result.arguments],
|
||||
exit_code: result.exitCode,
|
||||
signal: result.signal,
|
||||
raw_stdout: result.stdout,
|
||||
raw_stderr: result.stderr,
|
||||
},
|
||||
cause: new NativeCommandFailure(
|
||||
"codesign",
|
||||
"nonzero-exit",
|
||||
result.exitCode,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
display: display.value,
|
||||
requirements: requirements.value,
|
||||
entitlements: entitlements.value,
|
||||
});
|
||||
};
|
||||
|
||||
/** Compose displays, independent slice knowledge, verification, and local ticket evidence. */
|
||||
export const inspectNativeSignature = async (options: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly capture: SignatureCapture;
|
||||
readonly invocation: (
|
||||
capture: NativeCommandCapture,
|
||||
) => NativeCommandInvocation;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<Result<NativeObservation, AnalysisError>> => {
|
||||
const { target, capture, signal } = options;
|
||||
const binding = await bindSignatureTarget(target, signal);
|
||||
if (!binding.ok) return binding;
|
||||
const displays = await signatureDisplays(target.path, capture, signal);
|
||||
if (!displays.ok) return displays;
|
||||
const { display, requirements, entitlements } = displays.value;
|
||||
const unsigned = unsignedSignatureCapture(display, target.path);
|
||||
let canonical: string[] = [];
|
||||
try {
|
||||
canonical = [await realpath(target.path)];
|
||||
} catch {
|
||||
/* The display retains the observed path. */
|
||||
}
|
||||
const parsed = parseCodeSignature(display.stderr, unsigned, [
|
||||
target.path,
|
||||
...canonical,
|
||||
]);
|
||||
const entitlementValue = parseSignatureEntitlements(entitlements.stdout);
|
||||
const code = signedCodePath(target);
|
||||
const verify = (path: string) =>
|
||||
capture(["--verify", "--deep", "--strict", "--verbose=2", path], signal);
|
||||
const verified = await verify(code);
|
||||
if (!verified.ok) return verified;
|
||||
const captures = [display, requirements, entitlements, verified.value];
|
||||
let appleOrigin = false;
|
||||
let appleOriginFailure: string | undefined;
|
||||
if (!unsigned && (parsed.code_directory?.platform_identifier ?? 0) !== 0) {
|
||||
const anchor = await capture(["--verify", "-R=anchor apple", code], signal);
|
||||
if (!anchor.ok) return anchor;
|
||||
captures.push(anchor.value);
|
||||
appleOrigin = anchor.value.exitCode === 0;
|
||||
if (!appleOrigin) {
|
||||
const reason = codesignReasons(anchor.value, code)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join("\n");
|
||||
appleOriginFailure = reason || `codesign exited ${anchor.value.exitCode}`;
|
||||
}
|
||||
}
|
||||
const slices = await inspectSignatureSlices(
|
||||
target,
|
||||
{ format: parsed.format, requirements, entitlements, unsigned },
|
||||
capture,
|
||||
signal,
|
||||
);
|
||||
if (!slices.ok) return slices;
|
||||
captures.push(...slices.value.captures);
|
||||
const aggregateUnsigned =
|
||||
slices.value.signed === undefined ? unsigned : !slices.value.signed;
|
||||
// Verification is independent evidence: uncertain display probes must not
|
||||
// erase an explicit signature failure from the verifier.
|
||||
const reportedVerification = signatureVerification(
|
||||
verified.value,
|
||||
code,
|
||||
aggregateUnsigned,
|
||||
);
|
||||
const nestedPaths = await inspectNestedCodePaths(
|
||||
reportedVerification,
|
||||
signal,
|
||||
);
|
||||
const verification = nestedPaths.progressAmbiguous
|
||||
? signatureVerification(
|
||||
verified.value,
|
||||
code,
|
||||
aggregateUnsigned,
|
||||
"ambiguous",
|
||||
)
|
||||
: reportedVerification;
|
||||
const limitations = [
|
||||
...parsed.limitations,
|
||||
...SIGNATURE_POSTURE_LIMITATIONS,
|
||||
...slices.value.limitations,
|
||||
...nestedPaths.limitations,
|
||||
];
|
||||
if (entitlementValue.omittedPrototypeKeys !== 0)
|
||||
limitations.push(
|
||||
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
|
||||
);
|
||||
if (appleOriginFailure !== undefined)
|
||||
limitations.push(`Apple-origin check failed: ${appleOriginFailure}`);
|
||||
if (verification.status === "unknown")
|
||||
limitations.push(
|
||||
"Signature verification is inconclusive (permission, I/O or unrecognized diagnostic); this is not a proven broken signature.",
|
||||
);
|
||||
else if (codesignOperationalFailure(verified.value, code))
|
||||
limitations.push(
|
||||
"Some code could not be verified due to operational diagnostics; definitive signature failures elsewhere still prove invalidity.",
|
||||
);
|
||||
let mainInvalid =
|
||||
verification.status === "invalid" || verification.status === "unknown";
|
||||
if (mainInvalid && !unsigned && code !== target.path) {
|
||||
const main = await verify(target.path);
|
||||
if (!main.ok) return main;
|
||||
captures.push(main.value);
|
||||
mainInvalid = main.value.exitCode !== 0;
|
||||
}
|
||||
const provenance = captures.map(options.invocation);
|
||||
const ticket = await stapledTicket(target, signal);
|
||||
const version = await verifySignatureTarget(target, binding.value, signal);
|
||||
if (!version.ok) return version;
|
||||
const facets = deriveSecurityFacets({
|
||||
signed: !aggregateUnsigned,
|
||||
codeDirectory: parsed.code_directory,
|
||||
entitlements: entitlementValue.value,
|
||||
entitlementsKnown: unsigned || entitlements.exitCode === 0,
|
||||
mixedSlices: slices.value.mixed,
|
||||
appleOrigin,
|
||||
signatureInvalid: mainInvalid,
|
||||
});
|
||||
const result = inspectSignatureSchema.parse({
|
||||
...parsed,
|
||||
signed: !aggregateUnsigned,
|
||||
designated_requirement:
|
||||
/designated\s*=>\s*(.+)$/mu.exec(requirements.stdout)?.[1] ?? null,
|
||||
entitlements: entitlementValue.value,
|
||||
verification,
|
||||
stapled_ticket: ticket,
|
||||
security_facets: facets,
|
||||
provenance,
|
||||
limitations,
|
||||
});
|
||||
return ok({
|
||||
result: jsonValueSchema.parse(result),
|
||||
provenance,
|
||||
limitations: result.limitations,
|
||||
locations: [],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { InspectSignature } from "../domain/native/nativeInspection.js";
|
||||
import { ticketStructureIssue } from "./NotarizationTicket.js";
|
||||
|
||||
/** Notarization tickets are a few kilobytes; larger files are hashed in chunks. */
|
||||
const HASH_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
/** The code `codesign --verify` should check: the bundle when one was opened. */
|
||||
export const signedCodePath = (target: {
|
||||
readonly path: string;
|
||||
readonly sourcePath?: string;
|
||||
readonly bundleInfoPlist?: string;
|
||||
}): string => appBundle(target) ?? target.path;
|
||||
|
||||
/**
|
||||
* The app bundle directory a target was opened from. Only a bundle opened as a
|
||||
* directory carries its Info.plist; a regular file named `X.app` is not one.
|
||||
* The bundle is derived from the Info.plist location, not from the outer
|
||||
* opening path, so an iOS-on-Mac wrapper resolves to the inner `Wrapper/*.app`
|
||||
* bundle whose executable and ticket were actually inspected.
|
||||
*/
|
||||
const appBundle = (target: {
|
||||
readonly sourcePath?: string;
|
||||
readonly bundleInfoPlist?: string;
|
||||
}): string | undefined => {
|
||||
if (target.bundleInfoPlist === undefined) return undefined;
|
||||
const derived = bundleDirFromPlist(target.bundleInfoPlist);
|
||||
if (derived !== undefined) return derived;
|
||||
return target.sourcePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle directory containing an Info.plist: `.../Foo.app/Contents/Info.plist`
|
||||
* lives two levels below its bundle, while a flat `.../Foo.app/Info.plist`
|
||||
* lives one level below it.
|
||||
*/
|
||||
const bundleDirFromPlist = (plist: string): string | undefined => {
|
||||
const contentsSuffix = "/Contents/Info.plist";
|
||||
if (plist.endsWith(contentsSuffix))
|
||||
return plist.slice(0, -"/Contents/Info.plist".length) || undefined;
|
||||
const flatSuffix = "/Info.plist";
|
||||
if (plist.endsWith(flatSuffix))
|
||||
return plist.slice(0, -"/Info.plist".length) || undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a ticket stapled to an app bundle, which `stapler` stores at
|
||||
* `Contents/CodeResources`. Presence is observed; the ticket's validity and
|
||||
* Apple's notarization record are not checked, because that needs the network.
|
||||
*/
|
||||
export const stapledTicket = async (
|
||||
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
|
||||
signal?: AbortSignal,
|
||||
pathProbe: (path: string, phase: "before" | "after") => Promise<Stats> = (
|
||||
path,
|
||||
) => lstat(path),
|
||||
): Promise<InspectSignature["stapled_ticket"]> => {
|
||||
signal?.throwIfAborted();
|
||||
const bundle = appBundle(target);
|
||||
if (bundle === undefined) {
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
status: "not-applicable",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
const relative = "Contents/CodeResources";
|
||||
const path = join(bundle, relative);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const metadata = await pathProbe(path, "before");
|
||||
if (!metadata.isFile()) {
|
||||
signal?.throwIfAborted();
|
||||
return absent;
|
||||
}
|
||||
// Open nonblocking so a concurrent replacement with a FIFO cannot hang
|
||||
// inspect_signature; the opened handle is revalidated below.
|
||||
const handle = await open(
|
||||
path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
// The path was a regular file at lstat. A directory or FIFO now means
|
||||
// the ticket was replaced; absence would hide that change.
|
||||
if (!opened.isFile())
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
// If the path was replaced between lstat and open, or mutated while
|
||||
// hashing, the digest would describe the wrong bytes: dev and ino catch
|
||||
// replacement, while size and mtime catch same-inode writes. Report any
|
||||
// drift as changed instead of a stale digest.
|
||||
if (!sameTicketFile(opened, metadata))
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
const structureIssue = await ticketStructureIssue(
|
||||
async (offset, length) => {
|
||||
signal?.throwIfAborted();
|
||||
const buffer = new Uint8Array(length);
|
||||
let done = 0;
|
||||
while (done < length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
done,
|
||||
length - done,
|
||||
offset + done,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
done += bytesRead;
|
||||
}
|
||||
return buffer.subarray(0, done);
|
||||
},
|
||||
opened.size,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
|
||||
let size = 0;
|
||||
while (size < opened.size) {
|
||||
signal?.throwIfAborted();
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, opened.size - size),
|
||||
size,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
size += bytesRead;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const closing = await handle.stat();
|
||||
if (!sameTicketFile(closing, opened) || size !== opened.size)
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
// An atomic replacement swaps the pathname to a new inode while the
|
||||
// handle still describes the unlinked original: re-resolve the path and
|
||||
// refuse a digest that no longer belongs to the bundle.
|
||||
try {
|
||||
const current = await pathProbe(path, "after");
|
||||
if (!sameTicketFile(current, opened))
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const code = errorCode(cause);
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") throw cause;
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: structureIssue === null ? "present" : "unreadable",
|
||||
path: relative,
|
||||
sha256: hash.digest("hex"),
|
||||
size,
|
||||
reason: structureIssue,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
const code = errorCode(cause);
|
||||
if (code === "ENOENT" || code === "ENOTDIR") return absent;
|
||||
// A privacy or ACL denial leaves the ticket's presence unknown, not the
|
||||
// whole signature inspection.
|
||||
if (code === "EACCES" || code === "EPERM" || code === "EIO")
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: code,
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether two observations describe the same file version. Device and inode
|
||||
* catch replacement; size and mtime catch same-inode writes and truncations
|
||||
* whose digest would otherwise mix multiple file states.
|
||||
*/
|
||||
const sameTicketFile = (
|
||||
left: {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
readonly ctimeMs: number;
|
||||
},
|
||||
right: {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
readonly ctimeMs: number;
|
||||
},
|
||||
): boolean =>
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs;
|
||||
|
||||
const absent = {
|
||||
status: "absent",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
} as const;
|
||||
|
||||
const errorCode = (cause: unknown): string | undefined =>
|
||||
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
||||
? cause.code
|
||||
: undefined;
|
||||
|
||||
/** Limitations that keep verification and notarization claims bounded. */
|
||||
export const SIGNATURE_POSTURE_LIMITATIONS = [
|
||||
"Signature verification is local `codesign --verify --deep --strict`; certificate revocation, Gatekeeper policy, and Apple's notarization records are not checked.",
|
||||
"Security facets are derived from CodeDirectory flags and entitlements; runtime policy such as System Integrity Protection, AMFI, and setuid bits can further restrict a process.",
|
||||
"Ticket presence requires recognized local s8ch/g8tk container and DER framing; certificate authenticity, ticket signature validity, and ticket-to-code binding are not verified.",
|
||||
];
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { canonicalJson } from "../domain/comparisonSemantics.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { NativeCommandCapture } from "./CommandRunner.js";
|
||||
import { codesignReasons } from "./CodesignVerification.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
|
||||
import {
|
||||
signatureArchitectures,
|
||||
summarizeSignatureSlices,
|
||||
} from "./NativeSignatureSlices.js";
|
||||
|
||||
/** The provider owns process execution and typed failure translation. */
|
||||
export type SignatureCapture = (
|
||||
argv: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<NativeCommandCapture, AnalysisError>>;
|
||||
|
||||
/** Detect explicit unsigned display reasons, not words embedded in echoed pathnames. */
|
||||
export const unsignedSignatureCapture = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): boolean =>
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
codesignReasons(capture, path).some((reason) =>
|
||||
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
/** Slice observations and aggregate knowledge, with every contributing capture retained. */
|
||||
export interface SignatureSliceObservation {
|
||||
readonly mixed: boolean;
|
||||
readonly captures: readonly NativeCommandCapture[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly signed: boolean | undefined;
|
||||
readonly classificationUnknown: boolean;
|
||||
}
|
||||
|
||||
/** Proactively inspect independent signatures of universal Mach-O architectures. */
|
||||
export const inspectSignatureSlices = async (
|
||||
target: BinaryTarget,
|
||||
input: {
|
||||
readonly format: string | null;
|
||||
readonly requirements: NativeCommandCapture;
|
||||
readonly entitlements: NativeCommandCapture;
|
||||
readonly unsigned: boolean;
|
||||
},
|
||||
capture: SignatureCapture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureSliceObservation, AnalysisError>> => {
|
||||
const architectures = signatureArchitectures(input.format, target);
|
||||
const gate =
|
||||
!input.unsigned &&
|
||||
(unsignedSignatureCapture(input.requirements, target.path) ||
|
||||
unsignedSignatureCapture(input.entitlements, target.path));
|
||||
if (!gate && architectures.length <= 1)
|
||||
return ok({
|
||||
mixed: false,
|
||||
captures: [],
|
||||
limitations: [],
|
||||
signed: undefined,
|
||||
classificationUnknown: false,
|
||||
});
|
||||
const captures: NativeCommandCapture[] = [];
|
||||
const signed: string[] = [];
|
||||
const unsigned: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
const postures = new Map<string, string>();
|
||||
for (const architecture of architectures) {
|
||||
const display = await capture(
|
||||
["-d", "-a", architecture, "--verbose=4", target.path],
|
||||
signal,
|
||||
);
|
||||
if (!display.ok) return display;
|
||||
captures.push(display.value);
|
||||
if (display.value.exitCode !== 0) {
|
||||
(unsignedSignatureCapture(display.value, target.path)
|
||||
? unsigned
|
||||
: unclassified
|
||||
).push(architecture);
|
||||
continue;
|
||||
}
|
||||
signed.push(architecture);
|
||||
const entitlements = await capture(
|
||||
["-d", "--entitlements", ":-", "-a", architecture, target.path],
|
||||
signal,
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
captures.push(entitlements.value);
|
||||
const directory = parseCodeSignature(display.value.stderr, false, [
|
||||
target.path,
|
||||
]).code_directory;
|
||||
if (
|
||||
directory?.flags === null ||
|
||||
directory === null ||
|
||||
entitlements.value.exitCode !== 0
|
||||
) {
|
||||
unclassified.push(architecture);
|
||||
continue;
|
||||
}
|
||||
postures.set(
|
||||
architecture,
|
||||
canonicalJson(
|
||||
{
|
||||
flags: directory.flags.value,
|
||||
platform: directory.platform_identifier,
|
||||
entitlements: parseSignatureEntitlements(entitlements.value.stdout)
|
||||
.value,
|
||||
},
|
||||
"Signature slice posture",
|
||||
),
|
||||
);
|
||||
}
|
||||
const summary = summarizeSignatureSlices({
|
||||
signed,
|
||||
unsigned,
|
||||
unclassified,
|
||||
postures,
|
||||
});
|
||||
const limitations: string[] = [];
|
||||
if (unsigned.length > 0)
|
||||
limitations.push(`Unsigned Mach-O slices: ${unsigned.join(", ")}.`);
|
||||
if (summary.postureDiffers)
|
||||
limitations.push(
|
||||
"Signed Mach-O slices differ in CodeDirectory flags or entitlements; aggregate facets are unknown.",
|
||||
);
|
||||
if (unclassified.length > 0)
|
||||
limitations.push(
|
||||
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
|
||||
);
|
||||
if (gate && input.requirements.exitCode !== 0)
|
||||
limitations.push(
|
||||
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
if (gate && input.entitlements.exitCode !== 0)
|
||||
limitations.push(
|
||||
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
return ok({
|
||||
...summary,
|
||||
mixed: gate || summary.mixed,
|
||||
captures,
|
||||
limitations,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
|
||||
/** Architectures to probe, retaining subtypes from codesign and target inventory. */
|
||||
export const signatureArchitectures = (
|
||||
format: string | null,
|
||||
target: BinaryTarget,
|
||||
): string[] => {
|
||||
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
|
||||
format ?? "",
|
||||
)?.[1];
|
||||
const declared =
|
||||
universal?.split(/\s+/u).filter((name) => name.length > 0) ?? [];
|
||||
const available =
|
||||
target.kind === "executable" && target.format === "mach-o"
|
||||
? target.availableArchitectures.map((name) =>
|
||||
name === "x86" ? "i386" : name,
|
||||
)
|
||||
: [];
|
||||
return [...new Set([...declared, ...available])];
|
||||
};
|
||||
|
||||
/** Aggregate signing knowledge, keeping inconclusive probes distinct from unsigned. */
|
||||
export const summarizeSignatureSlices = (input: {
|
||||
readonly signed: readonly string[];
|
||||
readonly unsigned: readonly string[];
|
||||
readonly unclassified: readonly string[];
|
||||
readonly postures: ReadonlyMap<string, string>;
|
||||
}): {
|
||||
readonly signed: boolean | undefined;
|
||||
readonly mixed: boolean;
|
||||
readonly postureDiffers: boolean;
|
||||
readonly classificationUnknown: boolean;
|
||||
} => {
|
||||
const classificationUnknown = input.unclassified.length > 0;
|
||||
const postureDiffers = new Set(input.postures.values()).size > 1;
|
||||
const signed =
|
||||
input.signed.length > 0
|
||||
? true
|
||||
: classificationUnknown || input.unsigned.length === 0
|
||||
? undefined
|
||||
: false;
|
||||
return {
|
||||
signed,
|
||||
classificationUnknown,
|
||||
postureDiffers,
|
||||
mixed:
|
||||
classificationUnknown ||
|
||||
(input.signed.length > 0 && input.unsigned.length > 0) ||
|
||||
postureDiffers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { notarizationTicketFixture } from "../../tests/fixtures/notarizationTicket.js";
|
||||
import { ticketStructureIssue } from "./NotarizationTicket.js";
|
||||
|
||||
const inspect = (bytes: Uint8Array) =>
|
||||
ticketStructureIssue(
|
||||
async (offset, length) => bytes.subarray(offset, offset + length),
|
||||
bytes.length,
|
||||
);
|
||||
|
||||
it("recognizes local ticket framing without claiming cryptographic validity", async () => {
|
||||
expect(await inspect(notarizationTicketFixture())).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty, arbitrary, truncated and contradictory ticket containers", async () => {
|
||||
expect(await inspect(new Uint8Array())).toContain("Not a recognized");
|
||||
expect(await inspect(Buffer.from("stapled ticket bytes"))).toContain(
|
||||
"Not a recognized",
|
||||
);
|
||||
const ticket = Buffer.from(notarizationTicketFixture());
|
||||
expect(await inspect(ticket.subarray(0, ticket.length - 1))).toContain(
|
||||
"signature",
|
||||
);
|
||||
ticket.writeUInt32LE(2, 16 + 4 + 8);
|
||||
expect(await inspect(ticket)).toContain("hash records disagree");
|
||||
});
|
||||
|
||||
it("requires the complete signature field and validates all padding bytes", async () => {
|
||||
const bytes = Buffer.from(notarizationTicketFixture());
|
||||
expect(await inspect(bytes.subarray(0, bytes.length - 64))).toContain(
|
||||
"72 bytes",
|
||||
);
|
||||
expect(await inspect(bytes)).toBeNull();
|
||||
bytes[bytes.length - 1] = 1;
|
||||
expect(await inspect(bytes)).toContain("signature padding");
|
||||
expect(await inspect(Buffer.concat([bytes, Buffer.alloc(1)]))).toContain(
|
||||
"72 bytes",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Bounded positional reader used to inspect local ticket framing. */
|
||||
export type TicketReadAt = (
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Inspect the local s8ch/g8tk ticket framing. Observed in stapled app tickets;
|
||||
* see https://www.mothersruin.com/software/Archaeology/reverse/tickets.html.
|
||||
* This does not authenticate certificates, signatures, or notarized cdhashes.
|
||||
*/
|
||||
export const ticketStructureIssue = async (
|
||||
read: TicketReadAt,
|
||||
size: number,
|
||||
): Promise<string | null> => {
|
||||
const header = Buffer.from(await read(0, 16));
|
||||
if (header.length !== 16 || header.toString("ascii", 0, 4) !== "s8ch")
|
||||
return "Not a recognized s8ch notarization ticket container";
|
||||
if (header.readUInt32LE(4) !== 1)
|
||||
return `Unsupported notarization ticket container version ${header.readUInt32LE(4)}`;
|
||||
const certificateSize = header.readUInt32LE(8);
|
||||
const contentSize = header.readUInt32LE(12);
|
||||
const contentOffset = 16 + certificateSize;
|
||||
const signatureOffset = contentOffset + contentSize;
|
||||
if (certificateSize < 4 || contentSize < 24 || signatureOffset >= size)
|
||||
return "Ticket certificate, content, or signature extent is missing or truncated";
|
||||
const content = Buffer.from(await read(contentOffset, 24));
|
||||
if (content.length !== 24 || content.toString("ascii", 0, 4) !== "g8tk")
|
||||
return "Ticket content has no complete g8tk header";
|
||||
const hashSize = content.readUInt16LE(6);
|
||||
const hashCount = content.readUInt32LE(8);
|
||||
if (
|
||||
hashSize === 0 ||
|
||||
hashCount === 0 ||
|
||||
24 + (hashSize + 1) * hashCount !== contentSize
|
||||
)
|
||||
return "Ticket hash records disagree with the declared content length";
|
||||
const certificates = await derSequenceExtent(read, 16, certificateSize);
|
||||
if (certificates !== certificateSize)
|
||||
return "Ticket certificate block has malformed DER framing";
|
||||
const signatureFieldSize = 72;
|
||||
if (size - signatureOffset !== signatureFieldSize)
|
||||
return "Ticket signature field is truncated or does not contain exactly 72 bytes";
|
||||
const signatureSize = await derSequenceExtent(
|
||||
read,
|
||||
signatureOffset,
|
||||
signatureFieldSize,
|
||||
);
|
||||
if (signatureSize === null || signatureSize < 8)
|
||||
return "Ticket signature has malformed DER framing";
|
||||
// The fixed signature field contains DER followed by zero padding.
|
||||
const paddingSize = size - signatureOffset - signatureSize;
|
||||
const padding = await read(signatureOffset + signatureSize, paddingSize);
|
||||
if (padding.byteLength !== paddingSize || padding.some((byte) => byte !== 0))
|
||||
return "Ticket has malformed signature padding";
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Read only a DER SEQUENCE tag/length and check its extent against its section. */
|
||||
const derSequenceExtent = async (
|
||||
read: TicketReadAt,
|
||||
offset: number,
|
||||
available: number,
|
||||
): Promise<number | null> => {
|
||||
const prefix = Buffer.from(await read(offset, Math.min(6, available)));
|
||||
if (prefix.length < 2 || prefix[0] !== 0x30) return null;
|
||||
const first = prefix[1] ?? 0;
|
||||
if (first < 128) return first + 2 <= available ? first + 2 : null;
|
||||
const width = first & 0x7f;
|
||||
if (width === 0 || width > 4 || prefix.length < width + 2 || prefix[2] === 0)
|
||||
return null;
|
||||
let length = 0;
|
||||
for (let index = 0; index < width; index++)
|
||||
length = length * 256 + (prefix[index + 2] ?? 0);
|
||||
return length >= 128 && length + width + 2 <= available
|
||||
? length + width + 2
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, type BigIntStats } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
|
||||
/** Binding of commands to the selected executable's registered content/version. */
|
||||
export interface SignatureTargetBinding {
|
||||
readonly identity: string;
|
||||
}
|
||||
|
||||
const identity = (stat: BigIntStats): string =>
|
||||
[stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
||||
|
||||
const changedTarget = (target: BinaryTarget, reason: string) =>
|
||||
err(
|
||||
new AnalysisArtifactChangedError("inspect_signature", target.path, reason),
|
||||
);
|
||||
|
||||
const signatureReadFailure = (
|
||||
target: BinaryTarget,
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
if (code === "EACCES" || code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(
|
||||
"inspect_signature",
|
||||
target.path,
|
||||
code,
|
||||
{ cause },
|
||||
);
|
||||
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
|
||||
return new AnalysisArtifactChangedError(
|
||||
"inspect_signature",
|
||||
target.path,
|
||||
`Registered signature target is no longer readable (${code}): ${target.path}`,
|
||||
{ cause },
|
||||
);
|
||||
return new ProviderAdapterError(
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
"inspect_signature",
|
||||
{
|
||||
cause,
|
||||
diagnostics: {
|
||||
path: target.path,
|
||||
phase: "target-version-binding",
|
||||
system_code: typeof code === "string" ? code : null,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Establish a bounded-memory digest/version baseline before external commands run. */
|
||||
export const bindSignatureTarget = async (
|
||||
target: BinaryTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureTargetBinding, AnalysisError>> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const before = await lstat(target.path, { bigint: true });
|
||||
if (!before.isFile() || before.isSymbolicLink())
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target is no longer a regular file: ${target.path}`,
|
||||
);
|
||||
const file = await open(
|
||||
target.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const opened = await file.stat({ bigint: true });
|
||||
if (identity(opened) !== identity(before))
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target changed before open: ${target.path}`,
|
||||
);
|
||||
const size = Number(opened.size);
|
||||
if (!Number.isSafeInteger(size))
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"inspect_signature",
|
||||
"file-size",
|
||||
`Signature target size is not exactly representable: ${target.path}`,
|
||||
null,
|
||||
),
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
let position = 0;
|
||||
while (position < size) {
|
||||
signal?.throwIfAborted();
|
||||
const { bytesRead } = await file.read(
|
||||
chunk,
|
||||
0,
|
||||
Math.min(chunk.length, size - position),
|
||||
position,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target was truncated while hashing: ${target.path}`,
|
||||
);
|
||||
hash.update(chunk.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
const observed = hash.digest("hex");
|
||||
if (observed !== target.sha256)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target digest changed: ${target.path}; expected ${target.sha256}, observed ${observed}`,
|
||||
);
|
||||
const after = await file.stat({ bigint: true });
|
||||
const current = await lstat(target.path, { bigint: true });
|
||||
if (
|
||||
identity(after) !== identity(opened) ||
|
||||
identity(current) !== identity(opened)
|
||||
)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target changed while establishing its version: ${target.path}`,
|
||||
);
|
||||
return ok({ identity: identity(opened) });
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
return err(signatureReadFailure(target, cause));
|
||||
}
|
||||
};
|
||||
|
||||
/** Reject drift between display, entitlement and verification captures. */
|
||||
export const verifySignatureTarget = async (
|
||||
target: BinaryTarget,
|
||||
binding: SignatureTargetBinding,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<void, AnalysisError>> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const current = await lstat(target.path, { bigint: true });
|
||||
return current.isFile() && identity(current) === binding.identity
|
||||
? ok(undefined)
|
||||
: changedTarget(
|
||||
target,
|
||||
`Signature target changed during inspection: ${target.path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
return err(signatureReadFailure(target, cause));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCodeSignature } from "./codesign.js";
|
||||
|
||||
const display = (lines: readonly string[]): string =>
|
||||
[
|
||||
"Identifier=com.example.fixture",
|
||||
"Format=app bundle with Mach-O thin (arm64)",
|
||||
...lines,
|
||||
"TeamIdentifier=TEAM12345",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("codesign CodeDirectory parsing", () => {
|
||||
it("decodes CodeDirectory fields and flags from the numeric value", () => {
|
||||
const parsed = parseCodeSignature(
|
||||
display([
|
||||
"CodeDirectory v=20500 size=443 flags=0x12a00(kill,restrict,library-validation,runtime) hashes=3209+7 location=embedded",
|
||||
"Platform identifier=26",
|
||||
"Hash type=sha256 size=32",
|
||||
"Executable Segment flags=0x1",
|
||||
"Runtime Version=26.4.0",
|
||||
"Sealed Resources version=2 rules=13 files=290",
|
||||
]),
|
||||
false,
|
||||
);
|
||||
expect(parsed.code_directory).toEqual({
|
||||
version: "20500",
|
||||
flags: {
|
||||
value: 0x12a00,
|
||||
names: ["kill", "restrict", "library-validation", "runtime"],
|
||||
},
|
||||
code_slots: 3209,
|
||||
special_slots: 7,
|
||||
location: "embedded",
|
||||
hash_type: "sha256",
|
||||
platform_identifier: 26,
|
||||
runtime_version: "26.4.0",
|
||||
executable_segment_flags: 1,
|
||||
});
|
||||
expect(parsed.hardened_runtime).toBe(true);
|
||||
expect(parsed.sealed_resources).toEqual({
|
||||
status: "sealed",
|
||||
version: 2,
|
||||
rules: 13,
|
||||
files: 290,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unsealed, unsigned and incomplete output without guessing", () => {
|
||||
const bare = parseCodeSignature(
|
||||
display([
|
||||
"CodeDirectory v=20400 size=336 location=embedded",
|
||||
"Sealed Resources=none",
|
||||
]),
|
||||
false,
|
||||
);
|
||||
expect(bare.code_directory).toMatchObject({
|
||||
flags: null,
|
||||
code_slots: null,
|
||||
platform_identifier: null,
|
||||
});
|
||||
expect(bare.sealed_resources.status).toBe("none");
|
||||
expect(parseCodeSignature(display([]), false)).toMatchObject({
|
||||
code_directory: null,
|
||||
sealed_resources: { status: "unknown" },
|
||||
});
|
||||
expect(
|
||||
parseCodeSignature("fixture: code object is not signed at all\n", true),
|
||||
).toMatchObject({
|
||||
signed: false,
|
||||
code_directory: null,
|
||||
sealed_resources: { status: "none" },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type CodeDirectory } from "../../domain/native/codeSigningPosture.js";
|
||||
import { codeDirectoryFlagNames } from "../CodeSigningPolicy.js";
|
||||
import {
|
||||
inspectSignatureSchema,
|
||||
type InspectSignature,
|
||||
@@ -23,11 +25,21 @@ export const parseCodeSignature = (
|
||||
const values = new Map<string, string>();
|
||||
const authorities: string[] = [];
|
||||
const cdhashes: string[] = [];
|
||||
let sealed: InspectSignature["sealed_resources"] = {
|
||||
status: "unknown",
|
||||
version: null,
|
||||
rules: null,
|
||||
files: null,
|
||||
};
|
||||
for (const line of fields.split("\n")) {
|
||||
if (line.startsWith("CodeDirectory ")) {
|
||||
values.set("CodeDirectory", line.slice("CodeDirectory ".length));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("Sealed Resources")) {
|
||||
sealed = parseSealedResources(line);
|
||||
continue;
|
||||
}
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) continue;
|
||||
const key = line.slice(0, separator).trim();
|
||||
@@ -48,6 +60,19 @@ export const parseCodeSignature = (
|
||||
entitlements: null,
|
||||
timestamp: values.get("Timestamp") ?? null,
|
||||
hardened_runtime: parseRuntime(values.get("CodeDirectory")),
|
||||
code_directory: unsigned ? null : parseCodeDirectory(values),
|
||||
sealed_resources: unsigned
|
||||
? { status: "none", version: null, rules: null, files: null }
|
||||
: sealed,
|
||||
verification: null,
|
||||
stapled_ticket: {
|
||||
status: "not-applicable",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
},
|
||||
security_facets: [],
|
||||
limitations: unsigned
|
||||
? ["Artifact is not signed."]
|
||||
: [
|
||||
@@ -104,3 +129,62 @@ const parseRuntime = (value: string | undefined): boolean | null => {
|
||||
if (flags === undefined) return null;
|
||||
return flags.split(",").some((flag) => flag.trim() === "runtime");
|
||||
};
|
||||
|
||||
const integerField = (
|
||||
text: string | undefined,
|
||||
pattern: RegExp,
|
||||
radix = 10,
|
||||
): number | null => {
|
||||
const match = text === undefined ? undefined : pattern.exec(text)?.[1];
|
||||
if (match === undefined) return null;
|
||||
const value = Number.parseInt(match, radix);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `CodeDirectory v=20500 size=… flags=0x10000(runtime) hashes=3209+7
|
||||
* location=embedded`, plus the separate hash, platform, runtime-version and
|
||||
* executable-segment lines. Flag names are decoded from the numeric value.
|
||||
*/
|
||||
const parseCodeDirectory = (
|
||||
values: ReadonlyMap<string, string>,
|
||||
): CodeDirectory | null => {
|
||||
const line = values.get("CodeDirectory");
|
||||
if (line === undefined) return null;
|
||||
const flags = integerField(line, /(?:^|\s)flags=0x([0-9a-f]+)/iu, 16);
|
||||
return {
|
||||
version: /(?:^|\s)v=(\S+)/u.exec(line)?.[1] ?? null,
|
||||
flags:
|
||||
flags === null
|
||||
? null
|
||||
: { value: flags, names: codeDirectoryFlagNames(flags) },
|
||||
code_slots: integerField(line, /(?:^|\s)hashes=(\d+)\+/u),
|
||||
special_slots: integerField(line, /(?:^|\s)hashes=\d+\+(\d+)/u),
|
||||
location: /(?:^|\s)location=(\S+)/u.exec(line)?.[1] ?? null,
|
||||
hash_type: values.get("Hash type")?.split(/\s+/u)[0] ?? null,
|
||||
platform_identifier: integerField(
|
||||
values.get("Platform identifier"),
|
||||
/^(\d+)$/u,
|
||||
),
|
||||
runtime_version: values.get("Runtime Version") ?? null,
|
||||
executable_segment_flags: integerField(
|
||||
values.get("Executable Segment flags"),
|
||||
/^0x([0-9a-f]+)$/iu,
|
||||
16,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** `Sealed Resources version=2 rules=13 files=290` or `Sealed Resources=none`. */
|
||||
const parseSealedResources = (
|
||||
line: string,
|
||||
): InspectSignature["sealed_resources"] => {
|
||||
if (/^Sealed Resources\s*=\s*none$/u.test(line.trim()))
|
||||
return { status: "none", version: null, rules: null, files: null };
|
||||
return {
|
||||
status: "sealed",
|
||||
version: integerField(line, /\sversion=(\d+)/u),
|
||||
rules: integerField(line, /\srules=(\d+)/u),
|
||||
files: integerField(line, /\sfiles=(\d+)/u),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
MODE,
|
||||
gzipCpio,
|
||||
xarArchive,
|
||||
rewriteXarToc,
|
||||
} from "../../../src/artifacts/InstallerPackage.fixture.js";
|
||||
import { runProviderAnalysis } from "../../../src/composition/directAnalysis.js";
|
||||
import {
|
||||
artifactInventoryResultSchema,
|
||||
artifactExtractionResultSchema,
|
||||
} from "../../../src/domain/artifactGraph.js";
|
||||
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const sha256 = (text: string): string =>
|
||||
createHash("sha256").update(text).digest("hex");
|
||||
|
||||
const productPackage = (payloadText: string, extractedSha1?: string) =>
|
||||
xarArchive([
|
||||
{
|
||||
name: "Distribution",
|
||||
data: Buffer.from("<installer-gui-script/>"),
|
||||
encoding: "zlib",
|
||||
},
|
||||
{
|
||||
name: "App.pkg",
|
||||
type: "directory",
|
||||
children: [
|
||||
{
|
||||
name: "PackageInfo",
|
||||
data: Buffer.from('<pkg-info identifier="com.example.app"/>'),
|
||||
encoding: "zlib",
|
||||
...(extractedSha1 === undefined ? {} : { extractedSha1 }),
|
||||
},
|
||||
{
|
||||
name: "Scripts",
|
||||
data: gzipCpio([
|
||||
{ name: ".", mode: MODE.directory },
|
||||
{
|
||||
name: "./postinstall",
|
||||
mode: MODE.executable,
|
||||
data: "#!/bin/sh\n",
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: "Payload",
|
||||
data: gzipCpio([
|
||||
{ name: ".", mode: MODE.directory },
|
||||
{ name: "./Applications", mode: MODE.directory },
|
||||
{
|
||||
name: "./Applications/App.app/Contents/Info.plist",
|
||||
mode: MODE.file,
|
||||
data: payloadText,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
describe("installer package checksum evidence", () => {
|
||||
it("reports a stored SHA-1 mismatch independently of a valid decoded MD5", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-checksum-reason-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
const data = Buffer.from("readable content");
|
||||
const declared = "0".repeat(40);
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteXarToc(
|
||||
xarArchive([{ name: "member", data, archivedSha1: declared }]),
|
||||
(xml) =>
|
||||
xml.replace(
|
||||
/<extracted-checksum style="sha1">[^<]+<\/extracted-checksum>/u,
|
||||
`<extracted-checksum style="md5">${createHash("md5").update(data).digest("hex")}</extracted-checksum>`,
|
||||
),
|
||||
),
|
||||
);
|
||||
const { error, ...projection } = z
|
||||
.object({ error: z.string() })
|
||||
.passthrough()
|
||||
.parse(await runProviderAnalysis(path, "inventory_artifact", {}));
|
||||
expect(error).toBe("Analysis failed");
|
||||
const failed = analysisErrorProjectionSchema.parse(projection);
|
||||
expect(failed).toMatchObject({
|
||||
code: "artifact_integrity_mismatch",
|
||||
details: {
|
||||
logical_path: "member",
|
||||
checksum_mismatches: [
|
||||
{
|
||||
representation: "stored",
|
||||
algorithm: "sha1",
|
||||
declared,
|
||||
observed: createHash("sha1").update(data).digest("hex"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["raw", "zlib"] as const)(
|
||||
"hashes and extracts %s content with unsupported stored and decoded checksum algorithms",
|
||||
async (encoding) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-pkg-unknown-checksum-",
|
||||
);
|
||||
const path = join(directory, "Installer.pkg");
|
||||
const data = Buffer.from("readable content");
|
||||
await writeFile(
|
||||
path,
|
||||
rewriteXarToc(xarArchive([{ name: "member", data, encoding }]), (xml) =>
|
||||
xml
|
||||
.replaceAll(
|
||||
'<archived-checksum style="sha1">',
|
||||
'<archived-checksum style="unknown-stored">',
|
||||
)
|
||||
.replaceAll(
|
||||
'<extracted-checksum style="sha1">',
|
||||
'<extracted-checksum style="unknown-decoded">',
|
||||
),
|
||||
),
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
parseEvidence(await runProviderAnalysis(path, "inventory_artifact", {}))
|
||||
.normalized_result,
|
||||
);
|
||||
const member = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "member",
|
||||
);
|
||||
expect(member).toMatchObject({
|
||||
hash_status: "verified",
|
||||
limitations: [
|
||||
expect.stringContaining("unsupported checksum unknown-decoded"),
|
||||
expect.stringContaining(
|
||||
"unsupported archived checksum unknown-stored",
|
||||
),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
inventory.nodes.find(
|
||||
({ artifact_id }) => artifact_id === member?.artifact_id,
|
||||
),
|
||||
).toMatchObject({ sha256: sha256(data.toString()), size: data.length });
|
||||
const output = join(directory, "extracted");
|
||||
const extraction = artifactExtractionResultSchema.parse(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "extract_artifact", {
|
||||
output_root: output,
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
expect(extraction.artifacts).toHaveLength(1);
|
||||
expect(await readFile(join(output, "member"))).toEqual(data);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("installer package inventory", () => {
|
||||
it("inventories xar members and expands gzip-cpio scripts and payloads", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-inventory-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(path, productPackage("<plist/>"));
|
||||
const evidence = parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
expect(inventory.manifest.root_format).toBe("pkg");
|
||||
expect(inventory.limitations).toEqual([]);
|
||||
const occurrences = new Map(
|
||||
inventory.occurrences.map((occurrence) => [
|
||||
occurrence.logical_path,
|
||||
occurrence,
|
||||
]),
|
||||
);
|
||||
expect([...occurrences.keys()]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Distribution",
|
||||
"App.pkg/PackageInfo",
|
||||
"App.pkg/Scripts",
|
||||
"App.pkg/Scripts/postinstall",
|
||||
"App.pkg/Payload",
|
||||
"App.pkg/Payload/Applications/App.app/Contents/Info.plist",
|
||||
]),
|
||||
);
|
||||
const plist = occurrences.get(
|
||||
"App.pkg/Payload/Applications/App.app/Contents/Info.plist",
|
||||
);
|
||||
const node = inventory.nodes.find(
|
||||
({ artifact_id: id }) => id === plist?.artifact_id,
|
||||
);
|
||||
expect(node?.sha256).toBe(sha256("<plist/>"));
|
||||
expect(occurrences.get("App.pkg/Scripts/postinstall")?.executable).toBe(
|
||||
true,
|
||||
);
|
||||
const scripts = occurrences.get("App.pkg/Scripts");
|
||||
expect(
|
||||
occurrences.get("App.pkg/Scripts/postinstall")?.parent_occurrence_id,
|
||||
).toBe(scripts?.occurrence_id);
|
||||
});
|
||||
|
||||
it("fails on a member whose xar checksum disagrees with its bytes", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-tampered-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(path, productPackage("<plist/>", "0".repeat(40)));
|
||||
expect(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {}),
|
||||
).toMatchObject({
|
||||
error: "Analysis failed",
|
||||
code: "artifact_integrity_mismatch",
|
||||
details: { logical_path: "App.pkg/PackageInfo", declared_sha256: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("records a xar checksum mismatch under record-and-continue", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-recorded-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(path, productPackage("<plist/>", "0".repeat(40)));
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {
|
||||
integrity_policy: "record-and-continue",
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
const info = inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "App.pkg/PackageInfo",
|
||||
);
|
||||
const observedSha1 = createHash("sha1")
|
||||
.update('<pkg-info identifier="com.example.app"/>')
|
||||
.digest("hex");
|
||||
expect(info).toMatchObject({ hash_status: "mismatched" });
|
||||
expect(info?.limitations).toContain(
|
||||
`Declared decoded sha1 ${"0".repeat(40)} disagrees with observed ${observedSha1}.`,
|
||||
);
|
||||
expect(inventory.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
"1 member(s) disagree with a declared non-SHA-256 checksum",
|
||||
),
|
||||
);
|
||||
// Untampered members are still inventoried and expanded.
|
||||
expect(
|
||||
inventory.occurrences.some(
|
||||
({ logical_path: logical }) =>
|
||||
logical === "App.pkg/Scripts/postinstall",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("records an unresolved hard link as unavailable instead of empty", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-hardlink-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{
|
||||
name: "Payload",
|
||||
data: gzipCpio(
|
||||
[{ name: "./orphan", mode: MODE.file, ino: 3, links: 2 }],
|
||||
"newc",
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
parseEvidence(await runProviderAnalysis(path, "inventory_artifact", {}))
|
||||
.normalized_result,
|
||||
);
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "Payload/orphan",
|
||||
),
|
||||
).toMatchObject({
|
||||
artifact_id: null,
|
||||
hash_status: "unavailable",
|
||||
limitations: [expect.stringContaining("Hard-link bytes")],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installer package CRC recovery", () => {
|
||||
it("recovers later siblings after a misaligned CRC mismatch", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-crc-align-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{
|
||||
name: "Payload",
|
||||
// "abc" is 3 bytes (1 padding byte): a CRC failure here must not
|
||||
// desynchronize the following member.
|
||||
data: gzipCpio(
|
||||
[
|
||||
{ name: "./bad", mode: MODE.file, data: "abc", check: 1 },
|
||||
{ name: "./good", mode: MODE.file, data: "ok" },
|
||||
],
|
||||
"crc",
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {
|
||||
integrity_policy: "record-and-continue",
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
const bad = inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "Payload/bad",
|
||||
);
|
||||
expect(bad).toMatchObject({ hash_status: "mismatched" });
|
||||
expect(
|
||||
inventory.nodes.find(
|
||||
({ artifact_id }) => artifact_id === bad?.artifact_id,
|
||||
),
|
||||
).toMatchObject({ sha256: sha256("abc"), size: 3 });
|
||||
expect(bad?.limitations).toContain(
|
||||
"Declared decoded cpio-byte-sum 00000001 disagrees with observed 00000126.",
|
||||
);
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "Payload/good",
|
||||
),
|
||||
).toMatchObject({ hash_status: "verified" });
|
||||
});
|
||||
|
||||
it("keeps later siblings after a zero-size hard-link CRC mismatch", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-link-crc-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{
|
||||
name: "Payload",
|
||||
data: gzipCpio(
|
||||
[
|
||||
{ name: "./bad", mode: MODE.file, ino: 4, links: 2, check: 1 },
|
||||
{ name: "./good", mode: MODE.file, data: "ok" },
|
||||
],
|
||||
"crc",
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
parseEvidence(
|
||||
await runProviderAnalysis(path, "inventory_artifact", {
|
||||
integrity_policy: "record-and-continue",
|
||||
}),
|
||||
).normalized_result,
|
||||
);
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "Payload/bad",
|
||||
),
|
||||
).toMatchObject({ hash_status: "unavailable" });
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path: logical }) => logical === "Payload/good",
|
||||
),
|
||||
).toMatchObject({ hash_status: "verified" });
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js";
|
||||
import {
|
||||
MODE,
|
||||
gzipCpio,
|
||||
xarArchive,
|
||||
} from "../../../src/artifacts/InstallerPackage.fixture.js";
|
||||
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("extracts regular nested siblings while retaining skipped special-node evidence", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-extract-special-");
|
||||
const inputPath = join(directory, "Installer.pkg");
|
||||
const outputRoot = join(directory, "extracted");
|
||||
await writeFile(
|
||||
inputPath,
|
||||
xarArchive([
|
||||
{
|
||||
name: "Payload",
|
||||
data: gzipCpio([
|
||||
{ name: "./fifo", mode: MODE.fifo },
|
||||
{ name: "./usr/bin/tool", mode: MODE.executable, data: "tool bytes" },
|
||||
]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const extracted = await extractArtifact({
|
||||
inputPath,
|
||||
outputRoot,
|
||||
inputFormat: "pkg",
|
||||
});
|
||||
expect(await readFile(join(outputRoot, "Payload/usr/bin/tool"), "utf8")).toBe(
|
||||
"tool bytes",
|
||||
);
|
||||
expect(extracted.artifacts.map(({ relative_path }) => relative_path)).toEqual(
|
||||
["Payload/usr/bin/tool"],
|
||||
);
|
||||
expect(extracted.extraction_manifest.selected_occurrence_ids).toHaveLength(1);
|
||||
expect(extracted.limitations).toContainEqual(
|
||||
expect.stringContaining("Payload/fifo was not extracted"),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains unsupported-encoding occurrences under the default inventory policy", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-unsupported-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{ name: "encoded", encoding: "bzip2", data: Buffer.from("opaque") },
|
||||
{ name: "plain", data: Buffer.from("available") },
|
||||
]),
|
||||
);
|
||||
const inventory = await scanArtifactInventory(path);
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "encoded",
|
||||
),
|
||||
).toMatchObject({
|
||||
hash_status: "unavailable",
|
||||
artifact_id: null,
|
||||
limitations: [
|
||||
expect.stringContaining("Unsupported xar encoding application/x-bzip2"),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
inventory.occurrences.find(({ logical_path }) => logical_path === "plain"),
|
||||
).toMatchObject({ hash_status: "verified" });
|
||||
});
|
||||
|
||||
it("rejects a header whose decoded TOC length contradicts the actual data", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-toc-size-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([{ name: "plain", data: Buffer.from("ok") }], {
|
||||
tocDecodedSize: 1,
|
||||
}),
|
||||
);
|
||||
await expect(scanArtifactInventory(path)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringMatching(/xar TOC decoded \d+ bytes, expected 1/u),
|
||||
});
|
||||
});
|
||||
|
||||
it("records stored-checksum contradictions without losing decoded content identity", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-stored-integrity-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
const decoded = Buffer.from("same decoded bytes");
|
||||
const archivedSha1 = "0".repeat(40);
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{ name: "encoded", encoding: "zlib", data: decoded, archivedSha1 },
|
||||
{ name: "plain", data: Buffer.from("ok") },
|
||||
]),
|
||||
);
|
||||
const inventory = await scanArtifactInventory(path, {
|
||||
integrity: { mode: "record-and-continue" },
|
||||
});
|
||||
const entry = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "encoded",
|
||||
);
|
||||
expect(entry).toMatchObject({ hash_status: "mismatched" });
|
||||
expect(
|
||||
inventory.nodes.find(
|
||||
({ artifact_id }) => artifact_id === entry?.artifact_id,
|
||||
),
|
||||
).toMatchObject({
|
||||
size: decoded.length,
|
||||
sha256: createHash("sha256").update(decoded).digest("hex"),
|
||||
});
|
||||
const observed = createHash("sha1")
|
||||
.update(deflateSync(decoded))
|
||||
.digest("hex");
|
||||
expect(entry?.limitations).toContain(
|
||||
`Declared stored sha1 ${archivedSha1} disagrees with observed ${observed}.`,
|
||||
);
|
||||
await expect(scanArtifactInventory(path)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves recovered directory CRC mismatches through graph materialization", async () => {
|
||||
const directory = await createTestTempDirectory("rea-pkg-directory-crc-");
|
||||
const path = join(directory, "Installer.pkg");
|
||||
await writeFile(
|
||||
path,
|
||||
xarArchive([
|
||||
{
|
||||
name: "Payload",
|
||||
data: gzipCpio(
|
||||
[
|
||||
{ name: "dir", mode: MODE.directory, check: 1 },
|
||||
{ name: "dir/good", mode: MODE.file, data: "ok" },
|
||||
],
|
||||
"crc",
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inventory = await scanArtifactInventory(path, {
|
||||
integrity: { mode: "record-and-continue" },
|
||||
});
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "Payload/dir",
|
||||
),
|
||||
).toMatchObject({
|
||||
hash_status: "mismatched",
|
||||
limitations: [
|
||||
"Declared decoded cpio-byte-sum 00000001 disagrees with observed 00000000.",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "Payload/dir/good",
|
||||
),
|
||||
).toMatchObject({ hash_status: "verified" });
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { signatureVerification } from "../../../../src/native/CodesignVerification.js";
|
||||
import { NativeFixtureRunner } from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
it.each(["permission denied", "I/O error", "EACCES"])(
|
||||
"does not classify echoed pathname %s as an I/O failure",
|
||||
async (text) => {
|
||||
const path = `/Applications/${text}.app`;
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const observed = signatureVerification(
|
||||
{
|
||||
...capture.value,
|
||||
exitCode: 1,
|
||||
stderr: `${path}: a sealed resource is missing or invalid\n`,
|
||||
},
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(observed.status).toBe("invalid");
|
||||
expect(observed.diagnostics).toEqual([
|
||||
`${path}: a sealed resource is missing or invalid`,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps operational, unsigned, definite invalid and unrecognized failures distinct", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const status = (reason: string, unsigned = false) =>
|
||||
signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr: `${path}: ${reason}\n` },
|
||||
path,
|
||||
unsigned,
|
||||
).status;
|
||||
expect(status("permission denied")).toBe("unknown");
|
||||
expect(status("I/O error")).toBe("unknown");
|
||||
expect(status("invalid signature")).toBe("invalid");
|
||||
expect(status("main executable failed strict validation")).toBe("invalid");
|
||||
expect(status("bundle format unrecognized, invalid, or unsuitable")).toBe(
|
||||
"invalid",
|
||||
);
|
||||
expect(
|
||||
status(
|
||||
"resource fork, Finder information, or similar detritus not allowed",
|
||||
),
|
||||
).toBe("invalid");
|
||||
expect(status("code object is not signed at all", true)).toBe("unsigned");
|
||||
expect(status("code object is not signed at all")).toBe("invalid");
|
||||
expect(status("unrecognized failure")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("excludes complete selected paths with embedded newlines before classifying reasons", async () => {
|
||||
const path =
|
||||
"/Applications/permission denied\ncode object is not signed at all\nFixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr: `${path}: invalid signature\n` },
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(result.status).toBe("invalid");
|
||||
expect(result.diagnostics.join("\n")).toContain(path);
|
||||
});
|
||||
|
||||
it("preserves definitive invalidity when other components have operational failures", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const observed = signatureVerification(
|
||||
{
|
||||
...capture.value,
|
||||
exitCode: 1,
|
||||
stderr: `${path}/Contents/Helpers/one: permission denied\n${path}/Contents/Helpers/two: invalid signature\n`,
|
||||
},
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(observed.status).toBe("invalid");
|
||||
expect(observed.diagnostics).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves unmatched prepared records and ignores unprefixed nested-name fragments as reasons", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const stderr = `--prepared:${path}/Contents/Helpers/odd\ninvalid signature\n${path}: permission denied\n`;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr },
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.prepared_nested_code).toEqual([`${path}/Contents/Helpers/odd`]);
|
||||
expect(result.raw_stderr).toBe(stderr);
|
||||
expect(result.diagnostics).toContain("invalid signature");
|
||||
});
|
||||
|
||||
it("keeps failed verification unknown when nested progress can contain a selected-path diagnostic", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const stderr = `--prepared:${path}/Contents/Helpers/odd\n${path}: invalid signature\n${path}: permission denied\n`;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr },
|
||||
path,
|
||||
false,
|
||||
"ambiguous",
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.raw_stderr).toBe(stderr);
|
||||
expect(
|
||||
signatureVerification(
|
||||
{ ...capture.value, exitCode: 0, stderr },
|
||||
path,
|
||||
false,
|
||||
"ambiguous",
|
||||
).status,
|
||||
).toBe("valid");
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -18,9 +19,14 @@ import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
|
||||
import {
|
||||
NativeFixtureRunner as FixtureRunner,
|
||||
nativeFixture as fixture,
|
||||
nativeMachoTarget as machoTarget,
|
||||
nativeMachoTarget as unboundMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const machoTarget = (path: string, app?: string) => ({
|
||||
...unboundMachoTarget(path, app),
|
||||
sha256: createHash("sha256").update("fixture").digest("hex"),
|
||||
});
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
@@ -318,10 +324,13 @@ class PrototypeEntitlementsRunner extends FixtureRunner {
|
||||
|
||||
describe("native signature entitlements", () => {
|
||||
it("reports entitlement entries keyed __proto__ that the result omits", async () => {
|
||||
directory = await createTestTempDirectory("rea-signature-entitlements-");
|
||||
const executable = join(directory, "fixture");
|
||||
await writeFile(executable, "fixture");
|
||||
const client = new NativeMacOSProvider(
|
||||
new PrototypeEntitlementsRunner(),
|
||||
"darwin",
|
||||
).createClient(machoTarget("/private/fixture"));
|
||||
).createClient(machoTarget(executable));
|
||||
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import { stapledTicket } from "../../../../src/native/NativeSignaturePosture.js";
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { notarizationTicketFixture } from "../../../fixtures/notarizationTicket.js";
|
||||
import {
|
||||
NativeFixtureRunner as FixtureRunner,
|
||||
nativeMachoTarget as unboundMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const machoTarget = (path: string, app?: string) => ({
|
||||
...unboundMachoTarget(path, app),
|
||||
sha256: createHash("sha256").update("fixture").digest("hex"),
|
||||
});
|
||||
|
||||
const TAMPERED_STDERR =
|
||||
"--prepared:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/PlugIns/A.appex\n/Applications/Fixture.app: a sealed resource is missing or invalid\nfile modified: /Applications/Fixture.app/Contents/Resources/en.lproj/Main.nib\n";
|
||||
|
||||
/** Fail only strict verification, as a tampered bundle does. */
|
||||
class TamperedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = TAMPERED_STDERR.replaceAll(
|
||||
"/Applications/Fixture.app",
|
||||
arguments_.at(-1) ?? "",
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureApp = async (ticket: string | Uint8Array | undefined) => {
|
||||
const directory = await createTestTempDirectory("rea-signature-posture-");
|
||||
const app = join(directory, "Fixture.app");
|
||||
const executable = join(app, "Contents/MacOS/Fixture");
|
||||
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
|
||||
await writeFile(executable, "fixture");
|
||||
if (ticket !== undefined)
|
||||
await writeFile(join(app, "Contents/CodeResources"), ticket);
|
||||
return { app, executable };
|
||||
};
|
||||
|
||||
describe("native signature posture", () => {
|
||||
it("verifies the opened bundle and reports a stapled ticket", async () => {
|
||||
const ticket = notarizationTicketFixture();
|
||||
const { app, executable } = await fixtureApp(ticket);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
|
||||
expect(signature.ok).toBe(true);
|
||||
if (!signature.ok) return;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.code_directory).toMatchObject({
|
||||
version: "20500",
|
||||
flags: { value: 0x10000, names: ["runtime"] },
|
||||
code_slots: 10,
|
||||
special_slots: 7,
|
||||
});
|
||||
expect(result.verification).toMatchObject({
|
||||
path: app,
|
||||
status: "valid",
|
||||
exit_code: 0,
|
||||
});
|
||||
expect(result.stapled_ticket).toEqual({
|
||||
status: "present",
|
||||
path: "Contents/CodeResources",
|
||||
sha256: createHash("sha256").update(ticket).digest("hex"),
|
||||
size: ticket.length,
|
||||
reason: null,
|
||||
});
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
result.security_facets.map(({ facet, state }) => [facet, state]),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
"executable-memory": "restricted",
|
||||
"app-sandbox": "sandboxed",
|
||||
});
|
||||
expect(result.provenance.map(({ command }) => command[1])).toContain(
|
||||
"--verify",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a failed strict verification as an observation", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
await mkdir(join(app, "Contents/PlugIns/A.appex"), { recursive: true });
|
||||
await mkdir(join(app, "Contents/XPCServices/B.xpc"), { recursive: true });
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new TamperedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
|
||||
expect(signature.ok).toBe(true);
|
||||
if (!signature.ok) return;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification).toMatchObject({
|
||||
path: app,
|
||||
status: "invalid",
|
||||
exit_code: 1,
|
||||
diagnostics: [
|
||||
`${app}: a sealed resource is missing or invalid`,
|
||||
`file modified: ${app}/Contents/Resources/en.lproj/Main.nib`,
|
||||
],
|
||||
validated_nested_code: [
|
||||
`${app}/Contents/PlugIns/A.appex`,
|
||||
`${app}/Contents/XPCServices/B.xpc`,
|
||||
],
|
||||
});
|
||||
expect(result.stapled_ticket.status).toBe("absent");
|
||||
});
|
||||
|
||||
it("does not look for a stapled ticket beside a bare Mach-O", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-bare-");
|
||||
const executable = join(directory, "tool");
|
||||
await writeFile(executable, "fixture");
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: executable },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"keeps inspecting when the stapled ticket cannot be read",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp("ticket");
|
||||
const ticket = join(app, "Contents/CodeResources");
|
||||
await chmod(ticket, 0o000);
|
||||
try {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
stapled_ticket: {
|
||||
status: "unreadable",
|
||||
path: "Contents/CodeResources",
|
||||
sha256: null,
|
||||
reason: "EACCES",
|
||||
},
|
||||
verification: { status: "valid" },
|
||||
});
|
||||
} finally {
|
||||
await chmod(ticket, 0o644);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports unsigned nested code inside a signed bundle as invalid", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UnsignedNestedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
signed: true,
|
||||
verification: { status: "invalid", exit_code: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native signature posture boundaries", () => {
|
||||
it("verifies the inner bundle for iOS-on-Mac wrappers", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-wrapper-");
|
||||
const outer = join(directory, "WrapperApp.app");
|
||||
const inner = join(outer, "Wrapper", "Inner.app");
|
||||
const executable = join(inner, "Contents/MacOS/Tool");
|
||||
await mkdir(join(inner, "Contents/MacOS"), { recursive: true });
|
||||
await writeFile(executable, "fixture");
|
||||
const target = {
|
||||
...machoTarget(executable),
|
||||
sourcePath: outer,
|
||||
bundleInfoPlist: join(inner, "Contents/Info.plist"),
|
||||
};
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(target)
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: inner },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a regular file named .app as a bare Mach-O", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-file-");
|
||||
const executable = join(directory, "Tool.app");
|
||||
await writeFile(executable, "fixture");
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient({ ...machoTarget(executable), sourcePath: executable })
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: executable },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies platform policy only after the signature satisfies anchor apple", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const facets = async (anchorExit: number) => {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new PlatformRunner(anchorExit),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.provenance.map(({ command }) => command.at(-2))).toContain(
|
||||
"-R=anchor apple",
|
||||
);
|
||||
if (anchorExit === 0)
|
||||
expect(
|
||||
result.limitations.some((line) =>
|
||||
line.startsWith("Apple-origin check failed"),
|
||||
),
|
||||
).toBe(false);
|
||||
else
|
||||
expect(result.limitations).toContain(
|
||||
"Apple-origin check failed: code failed to satisfy specified code requirement(s)",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
result.security_facets.map(({ facet, state }) => [facet, state]),
|
||||
);
|
||||
};
|
||||
expect(await facets(0)).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
});
|
||||
expect(await facets(3)).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags reported nested code that does not exist as a split path", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new NewlinePathRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// The line projection splits the name; the fragment is flagged.
|
||||
expect(result.verification?.validated_nested_code).toEqual([
|
||||
`${app}/Contents/Helpers/odd`,
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
`validated nested code at ${JSON.stringify(`${app}/Contents/Helpers/odd`)}, which does not exist`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports cancellation while hashing the stapled ticket", async () => {
|
||||
const { app, executable } = await fixtureApp("ticket");
|
||||
const controller = new AbortController();
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new AbortAfterVerifyRunner(controller),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {}, { signal: controller.signal });
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) return;
|
||||
expect(signature.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native signature posture slice-aware cases", () => {
|
||||
it("keeps main-executable facets when only nested code fails", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new NestedOnlyFailureRunner(app),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("invalid");
|
||||
expect(
|
||||
result.security_facets.find(({ facet }) => facet === "library-validation")
|
||||
?.state,
|
||||
).toBe("enforced");
|
||||
});
|
||||
|
||||
it("probes every slice of a universal binary before declaring it signed", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalMixedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// Aggregate display calls report the signed native slice; the unsigned
|
||||
// x86_64 slice is found only per-architecture.
|
||||
expect(result.verification?.status).toBe("valid");
|
||||
expect(
|
||||
result.security_facets.find(
|
||||
({ facet }) => facet === "library-validation",
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "unknown",
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
});
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Unsigned Mach-O slices: x86_64."),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a trailing carriage return in reported nested paths", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new CarriageReturnPathRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// The CR is a valid pathname byte, not a line delimiter: it is kept and
|
||||
// the nonexistent path is flagged rather than silently normalized.
|
||||
expect(result.verification?.validated_nested_code).toEqual([
|
||||
`${app}/Contents/Helpers/cr\r`,
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("which does not exist"),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks facets unknown when signed slices differ in posture", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalPostureDiffRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// Every slice verifies, but their CodeDirectory flags disagree.
|
||||
expect(result.verification?.status).toBe("valid");
|
||||
expect(
|
||||
result.security_facets.find(
|
||||
({ facet }) => facet === "library-validation",
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "unknown",
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
});
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("differ in CodeDirectory flags or entitlements"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports verification I/O failures as unknown, not invalid", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new PermissionDeniedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("not a proven broken signature"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature architecture coverage", () => {
|
||||
it("does not let uncertain display probes hide definitive verification failure", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new DefiniteInvalidWithUncertainSlicesRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.signed).toBe(true);
|
||||
expect(result.verification?.status).toBe("invalid");
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("could not be classified"),
|
||||
);
|
||||
});
|
||||
it("does not turn inconclusive slice probes into an unsigned claim", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new InconclusiveSlicesRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.signed).toBe(true);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(
|
||||
result.security_facets.every(({ state }) => state === "unknown"),
|
||||
).toBe(true);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("could not be classified"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report arbitrary ticket-location bytes as a stapled ticket", async () => {
|
||||
const bytes = "stapled ticket bytes";
|
||||
const { app, executable } = await fixtureApp(bytes);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.stapled_ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
size: bytes.length,
|
||||
reason: "Not a recognized s8ch notarization ticket container",
|
||||
});
|
||||
});
|
||||
it("probes a valid x86_64h subtype rather than dropping it", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalMixedRunner("x86_64h"),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.limitations).toContain("Unsigned Mach-O slices: x86_64h.");
|
||||
expect(
|
||||
result.security_facets.every(({ state }) => state === "unknown"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains per-slice entitlement captures used to compare posture", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalPostureDiffRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
const commands = result.provenance.filter(
|
||||
({ command }) =>
|
||||
command.includes("-a") && command.includes("--entitlements"),
|
||||
);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(result.entitlements).toEqual({
|
||||
"com.apple.security.app-sandbox": true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature target version binding", () => {
|
||||
it("rejects a replacement before invoking signature tools", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const runner = new (class extends FixtureRunner {
|
||||
calls = 0;
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
this.calls += 1;
|
||||
return super.run(tool, args);
|
||||
}
|
||||
})();
|
||||
const client = new NativeMacOSProvider(runner, "darwin").createClient(
|
||||
machoTarget(executable, app),
|
||||
);
|
||||
await writeFile(executable, "replacement bytes");
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected target change");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: executable },
|
||||
});
|
||||
expect(runner.calls).toBe(0);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"rejects an unreadable selected executable",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const client = new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
).createClient(machoTarget(executable, app));
|
||||
await chmod(executable, 0);
|
||||
try {
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected access denial");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
details: { path: executable },
|
||||
});
|
||||
} finally {
|
||||
await chmod(executable, 0o600);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a regular ticket replaced by a directory as changed", async () => {
|
||||
const { app } = await fixtureApp(notarizationTicketFixture());
|
||||
const ticket = await stapledTicket(
|
||||
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
|
||||
undefined,
|
||||
async (path, phase) => {
|
||||
const metadata = await lstat(path);
|
||||
if (phase === "before") {
|
||||
await rm(path);
|
||||
await mkdir(path);
|
||||
}
|
||||
return metadata;
|
||||
},
|
||||
);
|
||||
expect(ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
path: "Contents/CodeResources",
|
||||
reason: "changed",
|
||||
sha256: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["EACCES", "EPERM", "EIO"])(
|
||||
"preserves final ticket-path failure %s instead of inventing mutation",
|
||||
async (code) => {
|
||||
const { app } = await fixtureApp(notarizationTicketFixture());
|
||||
const ticket = await stapledTicket(
|
||||
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
|
||||
undefined,
|
||||
async (path, phase) => {
|
||||
if (phase === "after")
|
||||
throw Object.assign(new Error("host lookup failed"), { code });
|
||||
return lstat(path);
|
||||
},
|
||||
);
|
||||
expect(ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
reason: code,
|
||||
sha256: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
it("rejects observations when the executable changes between captures", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new ReplacingExecutableRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected target change");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: executable },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"reports denied nested-path completeness checks",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const helpers = join(app, "Contents/Helpers");
|
||||
await mkdir(helpers);
|
||||
await chmod(helpers, 0);
|
||||
try {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
if (!result.ok || args[0] !== "--verify") return result;
|
||||
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
})(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.verification?.raw_stderr).toContain(
|
||||
`${app}: invalid signature`,
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Could not confirm completeness"),
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("permission denied (EACCES)"),
|
||||
);
|
||||
} finally {
|
||||
await chmod(helpers, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves failed display diagnostics through the caller-facing error contract", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const diagnostic = `${executable}: permission denied while reading signature\n`;
|
||||
const runner = new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
return result.ok && args.includes("--verbose=4")
|
||||
? ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr: diagnostic,
|
||||
stderrBytes: Buffer.byteLength(diagnostic),
|
||||
})
|
||||
: result;
|
||||
}
|
||||
})();
|
||||
const result = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected display failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
diagnostics: {
|
||||
path: executable,
|
||||
exit_code: 1,
|
||||
raw_stderr: diagnostic,
|
||||
arguments: ["-d", "--verbose=4", executable],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses filesystem-confirmed newline ambiguity to qualify verification without losing raw evidence", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const helpers = join(app, "Contents/Helpers");
|
||||
await mkdir(helpers);
|
||||
// A slash cannot be part of a POSIX basename. An absolute-looking continuation
|
||||
// is represented by nested directories following the newline in the prefix.
|
||||
const injected = join(helpers, `odd\n${app}: invalid signature`);
|
||||
await mkdir(injected, { recursive: true });
|
||||
const runner = new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
if (!result.ok || args[0] !== "--verify") return result;
|
||||
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
})();
|
||||
const observed = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!observed.ok) throw observed.error;
|
||||
const result = inspectSignatureSchema.parse(observed.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.verification?.raw_stderr).toContain(
|
||||
`${app}: invalid signature`,
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("structured nested-code records are ambiguous"),
|
||||
);
|
||||
});
|
||||
|
||||
/** Mutate only the fixture after its initial display has been observed. */
|
||||
class ReplacingExecutableRunner extends FixtureRunner {
|
||||
#changed = false;
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (
|
||||
!this.#changed &&
|
||||
tool === "codesign" &&
|
||||
arguments_.includes("--verbose=4")
|
||||
) {
|
||||
const path = arguments_.at(-1);
|
||||
if (path !== undefined)
|
||||
await writeFile(path, "replacement executable bytes");
|
||||
this.#changed = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** A signed bundle whose nested helper is unsigned. */
|
||||
class UnsignedNestedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr =
|
||||
"/Applications/Fixture.app/Contents/Helpers/tool: code object is not signed at all\nIn subcomponent: /Applications/Fixture.app/Contents/Helpers/tool\n";
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A platform binary whose `anchor apple` requirement check exits as given. */
|
||||
class PlatformRunner extends FixtureRunner {
|
||||
constructor(private readonly anchorExit: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (arguments_.includes("-R=anchor apple")) {
|
||||
const target = arguments_.at(-1) ?? "";
|
||||
const stderr =
|
||||
this.anchorExit === 0
|
||||
? ""
|
||||
: `${target}: code failed to satisfy specified code requirement(s)\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: this.anchorExit,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
if (!arguments_.includes("--verbose=4")) return result;
|
||||
const stderr = `${result.value.stderr}Platform identifier=26\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A nested helper whose file name contains a newline. */
|
||||
class NewlinePathRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const bundle = arguments_.at(-1) ?? "";
|
||||
const stderr = `--validated:${bundle}/Contents/Helpers/odd\nname.app\n${bundle}: valid on disk\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundle verification fails on nested code while the main executable passes. */
|
||||
class NestedOnlyFailureRunner extends FixtureRunner {
|
||||
constructor(private readonly bundle: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
if (arguments_.at(-1) !== this.bundle) return result;
|
||||
const stderr = `${this.bundle}: a sealed resource is missing or invalid\nfile modified: ${this.bundle}/Contents/XPCServices/Svc.xpc\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A universal binary whose aggregate display calls report the signed native
|
||||
* slice while the x86_64 slice is unsigned.
|
||||
*/
|
||||
class UniversalMixedRunner extends FixtureRunner {
|
||||
constructor(private readonly foreignArchitecture = "x86_64") {
|
||||
super();
|
||||
}
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
const architectureIndex = arguments_.indexOf("-a");
|
||||
if (architectureIndex >= 0) {
|
||||
if (arguments_[architectureIndex + 1] !== this.foreignArchitecture)
|
||||
return result;
|
||||
const stderr = `${arguments_.at(-1)}: code object is not signed at all\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
if (!arguments_.includes("--verbose=4")) return result;
|
||||
const stderr = result.value.stderr.replace(
|
||||
/^Format=.*$/mu,
|
||||
`Format=Mach-O universal (${this.foreignArchitecture} arm64)`,
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Aggregate display succeeds, but per-architecture probes and verification fail operationally. */
|
||||
class InconclusiveSlicesRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (!arguments_.includes("-a") && arguments_[0] !== "--verify")
|
||||
return result;
|
||||
const stderr = "I/O error reading signing data\n";
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Slice display uncertainty and an independently established invalid signature. */
|
||||
class DefiniteInvalidWithUncertainSlicesRunner extends InconclusiveSlicesRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = `${arguments_.at(-1)}: invalid signature\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A nested helper whose file name ends in a carriage return. */
|
||||
class CarriageReturnPathRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const bundle = arguments_.at(-1) ?? "";
|
||||
const stderr = `--validated:${bundle}/Contents/Helpers/cr\r\n${bundle}: valid on disk\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A universal binary whose slices all verify but carry different
|
||||
* CodeDirectory flags (hardened arm64, plain x86_64).
|
||||
*/
|
||||
class UniversalPostureDiffRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (arguments_.includes("--entitlements")) return result;
|
||||
const architectureIndex = arguments_.indexOf("-a");
|
||||
const architecture =
|
||||
architectureIndex < 0 ? null : arguments_[architectureIndex + 1];
|
||||
let stderr = result.value.stderr;
|
||||
if (architecture === null)
|
||||
stderr = stderr.replace(
|
||||
/^Format=.*$/mu,
|
||||
"Format=Mach-O universal (x86_64 arm64)",
|
||||
);
|
||||
else if (architecture === "x86_64")
|
||||
stderr = stderr.replace(
|
||||
/flags=0x[0-9a-f]+\([^)]*\)/u,
|
||||
"flags=0x2(adhoc)",
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Verification fails because nested code cannot be read. */
|
||||
class PermissionDeniedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = `${arguments_.at(-1)}: permission denied while reading nested code\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the request once local verification has finished. */
|
||||
class AbortAfterVerifyRunner extends FixtureRunner {
|
||||
constructor(private readonly controller: AbortController) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (arguments_[0] === "--verify") this.controller.abort();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Vendored
+9
-2
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
import type {
|
||||
@@ -95,10 +96,16 @@ const capture = (
|
||||
/** Build the Mach-O target identity used by native boundary fixtures. */
|
||||
export const nativeMachoTarget = (
|
||||
path: string,
|
||||
sourcePath?: string,
|
||||
/** The app bundle directory the target was opened from. */
|
||||
bundle?: string,
|
||||
): BinaryTarget => ({
|
||||
path,
|
||||
...(sourcePath === undefined ? {} : { sourcePath }),
|
||||
...(bundle === undefined
|
||||
? {}
|
||||
: {
|
||||
sourcePath: bundle,
|
||||
bundleInfoPlist: join(bundle, "Contents/Info.plist"),
|
||||
}),
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
/** Synthetic local ticket framing; it does not contain an authenticated certificate/signature. */
|
||||
export const notarizationTicketFixture = (): Uint8Array => {
|
||||
const certificates = Buffer.from([0x30, 2, 0x30, 0]);
|
||||
const content = Buffer.alloc(45);
|
||||
content.write("g8tk", 0, "ascii");
|
||||
content.writeUInt16LE(2, 4);
|
||||
content.writeUInt16LE(20, 6);
|
||||
content.writeUInt32LE(1, 8);
|
||||
content[24] = 2;
|
||||
content.fill(0xab, 25);
|
||||
const signature = Buffer.alloc(72);
|
||||
signature.set([0x30, 6, 2, 1, 1, 2, 1, 1]);
|
||||
const header = Buffer.alloc(16);
|
||||
header.write("s8ch", 0, "ascii");
|
||||
header.writeUInt32LE(1, 4);
|
||||
header.writeUInt32LE(certificates.length, 8);
|
||||
header.writeUInt32LE(content.length, 12);
|
||||
return Buffer.concat([header, certificates, content, signature]);
|
||||
};
|
||||
Reference in New Issue
Block a user