Compare commits

..
Author SHA1 Message Date
morluto 2e636ac58e test(native): isolate signature rejection fixtures 2026-10-08 23:55:06 +08:00
morluto 5eebf19e93 chore: sync branch with latest main 2026-10-08 23:52:20 +08:00
morluto 78a59d4e8f fix(native): bind signature results and preserve verification uncertainty 2026-10-08 23:47:39 +08:00
morluto 859ce82569 chore: merge main and reconcile CI projections 2026-10-08 23:45:07 +08:00
morluto ae5e778de2 fix(ci): restore generated projections in test shards 2026-10-08 23:32:34 +08:00
morluto 839f3e3d8e chore: synchronize signature posture branch with latest main guidance 2026-10-08 23:16:56 +08:00
morluto c1535d88ab fix(native): preserve signature uncertainty and validate ticket framing 2026-10-08 23:16:28 +08:00
d46449e54b fix(native): keep signature failure evidence instead of collapsing it (#1084)
A regular CodeResources file replaced by a directory or FIFO before open
was reported absent. That hid the replacement; it is now unreadable with
reason changed.

A failed anchor apple probe kept only an exit code. The caller-visible
limitation now includes the probe's diagnostic.

Definitive codesign rejections are a phrase list, including unrecognized
bundle format, instead of one growing regular expression. Unlisted
nonzero diagnostics stay unknown.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-08 22:41:28 +08:00
morluto 1325bf936f chore: merge main into feat/signature-posture 2026-10-08 21:29:24 +08:00
morluto 75460dae2f chore: merge main into feat/signature-posture 2026-10-08 21:20:04 +08:00
morluto d63b903535 fix(native): validate prepared and validated path records consistently 2026-10-08 21:17:12 +08:00
morluto 2d6f1cd23b fix(native): recognize forbidden extended attributes as invalid signing 2026-10-08 20:39:55 +08:00
morluto 1a5eec54e1 fix(native): preserve verification captures and separate path fragments 2026-10-08 20:35:31 +08:00
morluto fa68ca054c chore: merge main into feat/signature-posture 2026-10-08 19:59:45 +08:00
morluto bd2655e6c5 fix(native): preserve definitive failures alongside operational diagnostics 2026-10-08 19:52:21 +08:00
morluto 38e2a342d1 refactor(native): bound nested path checks and verify real ticket coverage 2026-10-08 19:35:56 +08:00
morluto 670b657d0e fix(native): keep path bytes out of signing reason classification 2026-10-08 18:46:13 +08:00
morluto f1f2aa3d86 refactor(native): own signing policy and bind capture target versions 2026-10-08 18:14:58 +08:00
morluto c202ee9479 refactor(native): isolate signature workflow and evidence classification 2026-10-08 17:43:48 +08:00
morluto d93f8209d4 fix(native): centralize slice knowledge and validate local ticket framing 2026-10-08 17:20:41 +08:00
morluto e45865f347 chore: merge main into feat/signature-posture 2026-10-08 16:59:55 +08:00
morluto 6a5cedfec4 fix(native): compare signed slice posture and reject ticket mutation 2026-10-08 16:59:08 +08:00
morluto d13143b5c5 docs: regenerate catalog and skill metadata 2026-10-08 16:43:33 +08:00
morluto cc706f347f fix(native): unknown verification on I/O failure, recheck ticket path, keep CR paths 2026-10-08 16:43:03 +08:00
morluto 96c2a93755 fix(native): probe every slice of universal binaries before aggregate signing state 2026-10-08 16:31:19 +08:00
morluto 1652c73902 fix(native): separate main-signature facets and I/O diagnostics from invalid 2026-10-08 16:12:06 +08:00
morluto e3c15c8bbc chore: merge main into feat/signature-posture 2026-10-08 15:49:33 +08:00
morluto cc78c18c47 fix(native): preserve whitespace in nested paths and detect ticket replacement 2026-10-08 15:45:34 +08:00
morluto 8f91add240 fix(native): harden signature posture for wrapped bundles, tickets, and invalid signatures 2026-10-08 15:24:29 +08:00
Kaoru0822andClaude Opus 5.5 33def5ae06 chore: merge main into feat/signature-posture
main now carries #1037 at skill version 30, so this branch moves to 31.
Regenerate the product catalog, conformance manifest, skill digest and
package metadata on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:28:32 +00:00
Kaoru0822andClaude Opus 5.5 48813a7de9 chore: merge main into feat/signature-posture
main now carries #964 at skill version 29, so this branch moves to 30.
Regenerate the product catalog, conformance manifest, skill digest and
package metadata on top of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:20:14 +00:00
Kaoru0822andClaude Opus 5.5 ea88d301f8 chore: merge main into feat/signature-posture
Resolve generated-metadata conflicts by regenerating them on top of
main. The skill references gain the inspect_signature paragraph, so the
skill version moves to 29 (main already uses 28). The MCP tool catalog
is now built rather than committed (#997), so it is removed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:43:20 +00:00
Kaoru0822andClaude Opus 5.5 a427ddf797 Merge origin/main into feat/signature-posture
Follow #973's native ownership: the code-signing posture schemas move to
src/domain/native/ beside nativeInspection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:48:16 +00:00
Kaoru0822 423a89e7e4 Merge remote-tracking branch 'origin/main' into feat/signature-posture
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 14:14:16 +00:00
Kaoru0822andClaude Opus 5.5 57e91187cf Merge origin/main into feat/signature-posture
Follow #952's layout: the code-signature lane moves to
scripts/verify/apple/code-signature.mjs and uses the relocated bundle
fixture builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:37:22 +00:00
Kaoru0822andClaude Opus 5.5 949ab427fb fix(native): flag nested-code paths that codesign split on newlines
codesign prints one nested path per line, so a nested component whose name
contains a newline is split into fragments. inspect_signature now checks
that every --validated: path it reports exists, and names any that does not
in a limitation instead of trusting it as a complete path. The result stays
deterministic: codesign's progress lines arrive in no fixed order, so the raw
output is not echoed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:05:23 +00:00
Kaoru0822 4f46836178 Merge remote-tracking branch 'origin/main' into feat/signature-posture
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 13:01:47 +00:00
Kaoru0822andClaude Opus 5.5 ce8e969112 fix(native): require Apple origin for platform facets and keep cancellation
- Treat a CodeDirectory platform identifier as a platform binary only when
  the signature satisfies `anchor apple`, checked with
  `codesign --verify -R="anchor apple"`; otherwise the dependent facets are
  unknown. Developer ID code chains to Apple Root CA but fails this check.
- Report every security facet as unknown when architecture slices differ in
  signing state.
- Map an aborted signal that escapes a native helper, such as ticket
  hashing, to AnalysisCancelledError instead of an output error.
- Treat a target as an app bundle only when it was opened from a bundle
  directory, so a regular file named X.app is verified as a bare Mach-O.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:43:47 +00:00
Kaoru0822 73eb20a4b9 Merge remote-tracking branch 'origin/main' into feat/signature-posture
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 12:37:43 +00:00
Kaoru0822andClaude Opus 5.5 94243fa5a1 refactor(native): extract signature verification from inspect_signature
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:55:05 +00:00
Kaoru0822andClaude Opus 5.5 37824e8c46 fix(native): verify nested code and keep ticket read failures local
Address review findings in inspect_signature:
- Verify with `codesign --verify --deep --strict`. Shallow verification
  misses a file added inside a nested XPC service, or a modified dylib
  inside a framework. verify:code-signature now tampers inside the
  nested XPC service and requires `invalid`.
- Classify verification as `unsigned` only when the top-level target is
  unsigned. Unsigned nested code inside a signed bundle is `invalid`.
- Report a stapled ticket that exists but cannot be read as
  `unreadable`, with the error code in a new `reason` field, instead of
  failing the whole signature inspection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:54:32 +00:00
Kaoru0822andClaude Opus 5.5 25ca63f231 feat(native): report code-signing posture in inspect_signature
inspect_signature reduced the CodeDirectory to a hardened-runtime boolean
and never verified the signature. It now also reports:

- the parsed CodeDirectory: version, flags decoded from their numeric
  value (runtime, library-validation, restrict, kill, adhoc,
  linker-signed, unknown bits in hex), code and special slot counts,
  location, hash type, platform identifier, runtime version, and
  executable-segment flags;
- sealed resources (version, rules, files, or none);
- a local `codesign --verify --strict` result on the opened bundle or
  Mach-O, with diagnostics and the sorted nested code it validated;
- whether a notarization ticket is stapled at Contents/CodeResources,
  with its digest. The ticket and Apple's records are not checked
  because that needs the network;
- derived security facets with their supporting evidence: library
  validation, DYLD_* handling, debugger attach, executable-memory
  policy, and App Sandbox. Facets stay unknown when flags or aggregate
  entitlements are unavailable.

verify:code-signature re-signs copies of the clang-built fixture with
real codesign (ad hoc, hardened runtime, hardened runtime with relaxing
entitlements, library,restrict). It also checks a platform binary and a
tampered bundle, with CLI/MCP parity, and runs in the macOS CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:16:55 +00:00
57 changed files with 3881 additions and 4573 deletions
+1 -1
View File
@@ -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
+1 -23
View File
@@ -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",
-6
View File
@@ -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
View File
@@ -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,
-1
View File
@@ -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
View File
@@ -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",
+291
View File
@@ -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 });
}
-174
View File
@@ -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
-78
View File
@@ -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);
}
}
+1 -12
View File
@@ -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) };
};
+1 -1
View File
@@ -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.",
]),
};
+7 -29
View File
@@ -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" });
});
-60
View File
@@ -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;
};
-94
View File
@@ -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();
}),
);
}
};
-804
View File
@@ -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;
}
}
-167
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
/** 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();
}
}
}
},
);
-875
View File
@@ -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();
}
}
-106
View File
@@ -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}`;
}
}
+34 -62
View File
@@ -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";
}
};
+2 -9
View File
@@ -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."];
+10 -31
View File
@@ -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.`,
]),
];
+92 -116
View File
@@ -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,
+1 -1
View File
@@ -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(
-10
View File
@@ -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(),
})
-12
View File
@@ -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 {
+6 -18
View File
@@ -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,
) {
+101
View File
@@ -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>;
+12
View File
@@ -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()),
});
+245
View File
@@ -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",
});
});
});
+395
View File
@@ -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;
};
+141
View File
@@ -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)),
};
};
+16 -208
View File
@@ -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.
+78
View File
@@ -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);
});
+105
View File
@@ -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 };
};
+11
View File
@@ -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[];
}
+15
View File
@@ -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 };
};
+234
View File
@@ -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: [],
});
};
+255
View File
@@ -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.",
];
+148
View File
@@ -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,
});
};
+51
View File
@@ -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,
};
};
+39
View File
@@ -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",
);
});
+78
View File
@@ -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;
};
+161
View File
@@ -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));
}
};
+77
View File
@@ -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" },
});
});
});
+84
View File
@@ -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;
}
}
+9 -2
View File
@@ -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",
+19
View File
@@ -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]);
};