Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e636ac58e | ||
|
|
5eebf19e93 | ||
|
|
78a59d4e8f | ||
|
|
859ce82569 | ||
|
|
ae5e778de2 | ||
|
|
839f3e3d8e | ||
|
|
c1535d88ab | ||
|
|
d46449e54b | ||
|
|
1325bf936f | ||
|
|
75460dae2f | ||
|
|
d63b903535 | ||
|
|
2d6f1cd23b | ||
|
|
1a5eec54e1 | ||
|
|
fa68ca054c | ||
|
|
bd2655e6c5 | ||
|
|
38e2a342d1 | ||
|
|
670b657d0e | ||
|
|
f1f2aa3d86 | ||
|
|
c202ee9479 | ||
|
|
d93f8209d4 | ||
|
|
e45865f347 | ||
|
|
6a5cedfec4 | ||
|
|
d13143b5c5 | ||
|
|
cc706f347f | ||
|
|
96c2a93755 | ||
|
|
1652c73902 | ||
|
|
e3c15c8bbc | ||
|
|
cc78c18c47 | ||
|
|
8f91add240 | ||
|
|
33def5ae06 | ||
|
|
48813a7de9 | ||
|
|
ea88d301f8 | ||
|
|
a427ddf797 | ||
|
|
423a89e7e4 | ||
|
|
57e91187cf | ||
|
|
949ab427fb | ||
|
|
4f46836178 | ||
|
|
ce8e969112 | ||
|
|
73eb20a4b9 | ||
|
|
94243fa5a1 | ||
|
|
37824e8c46 | ||
|
|
25ca63f231 |
@@ -102,12 +102,8 @@ jobs:
|
||||
with:
|
||||
name: rea-dist
|
||||
path: dist
|
||||
- name: Restore generated outputs from downloaded build
|
||||
run: |
|
||||
node scripts/generate-mcp-tool-catalog.mjs
|
||||
node scripts/generate-skill-metadata.mjs
|
||||
node scripts/generate-product-catalog.mjs
|
||||
node scripts/generate-completion-ledger.mjs
|
||||
- name: Generate projections from downloaded build
|
||||
run: npm run build:projections
|
||||
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -321,6 +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: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
|
||||
|
||||
@@ -98,6 +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: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,
|
||||
|
||||
+3
-1
@@ -87,7 +87,8 @@
|
||||
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"prebuild": "npm run deps:check && npm run metadata:check",
|
||||
"build": "node scripts/run-exclusive.mjs build npm run build:unlocked",
|
||||
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
|
||||
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && npm run build:projections",
|
||||
"build:projections": "node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
|
||||
"precheck": "npm run deps:check",
|
||||
"check": "turbo run typecheck lint format:check knip metadata:check",
|
||||
"precheck:fast": "npm run deps:check",
|
||||
@@ -141,6 +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: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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -91,6 +91,12 @@ 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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Observed signing-directory metadata; policy interpretation belongs to the adapter. */
|
||||
export const codeDirectorySchema = z.object({
|
||||
version: z.string().nullable(),
|
||||
flags: z
|
||||
.object({
|
||||
value: z.number().int().nonnegative(),
|
||||
names: z.array(z.string()),
|
||||
})
|
||||
.nullable(),
|
||||
code_slots: z.number().int().nonnegative().nullable(),
|
||||
special_slots: z.number().int().nonnegative().nullable(),
|
||||
location: z.string().nullable(),
|
||||
hash_type: z.string().nullable(),
|
||||
platform_identifier: z.number().int().nonnegative().nullable(),
|
||||
runtime_version: z.string().nullable(),
|
||||
executable_segment_flags: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
/** Observed sealed-resource summary. */
|
||||
export const sealedResourcesSchema = z.object({
|
||||
status: z.enum(["sealed", "none", "unknown"]),
|
||||
version: z.number().int().nullable(),
|
||||
rules: z.number().int().nullable(),
|
||||
files: z.number().int().nullable(),
|
||||
});
|
||||
|
||||
/** Local verification observation; unknown is distinct from a proven invalid signature. */
|
||||
export const signatureVerificationSchema = z.object({
|
||||
path: z.string(),
|
||||
status: z.enum(["valid", "invalid", "unsigned", "unknown"]),
|
||||
exit_code: z.number().int().nullable(),
|
||||
diagnostics: z.array(z.string()),
|
||||
validated_nested_code: z.array(z.string()),
|
||||
prepared_nested_code: z.array(z.string()).default([]),
|
||||
raw_stdout: z.string().default(""),
|
||||
raw_stderr: z.string().default(""),
|
||||
});
|
||||
|
||||
/** Local ticket presence and content identity, without a remote notarization claim. */
|
||||
export const stapledTicketSchema = z.object({
|
||||
status: z.enum(["present", "absent", "unreadable", "not-applicable"]),
|
||||
path: z.string().nullable(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
size: z.number().int().nonnegative().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
|
||||
const facetSchema = z.discriminatedUnion("facet", [
|
||||
z.object({
|
||||
facet: z.literal("library-validation"),
|
||||
state: z.enum(["enforced", "disabled", "not-enforced", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("dyld-environment-variables"),
|
||||
state: z.enum(["ignored", "honored", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("debugger-attach"),
|
||||
state: z.enum(["allowed", "blocked", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("executable-memory"),
|
||||
state: z.enum([
|
||||
"restricted",
|
||||
"jit-allowed",
|
||||
"unsigned-allowed",
|
||||
"protection-disabled",
|
||||
"unrestricted",
|
||||
"unknown",
|
||||
]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
facet: z.literal("app-sandbox"),
|
||||
state: z.enum(["sandboxed", "not-sandboxed", "unknown"]),
|
||||
basis: z.literal("derived"),
|
||||
evidence: z.array(z.string()),
|
||||
explanation: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Shared result semantics; adapters determine each facet's policy and evidence. */
|
||||
export const securityFacetsSchema = z.array(facetSchema);
|
||||
export type CodeDirectory = z.infer<typeof codeDirectorySchema>;
|
||||
export type SecurityFacet = z.infer<typeof facetSchema>;
|
||||
@@ -1,4 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
codeDirectorySchema,
|
||||
sealedResourcesSchema,
|
||||
securityFacetsSchema,
|
||||
signatureVerificationSchema,
|
||||
stapledTicketSchema,
|
||||
} from "./codeSigningPosture.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
|
||||
const hexAddress = z.string().regex(/^0x[a-fA-F0-9]+$/u);
|
||||
@@ -113,6 +120,11 @@ export const inspectSignatureSchema = z.object({
|
||||
entitlements: jsonValueSchema.nullable(),
|
||||
timestamp: z.string().nullable(),
|
||||
hardened_runtime: z.boolean().nullable(),
|
||||
code_directory: codeDirectorySchema.nullable(),
|
||||
sealed_resources: sealedResourcesSchema,
|
||||
verification: signatureVerificationSchema.nullable(),
|
||||
stapled_ticket: stapledTicketSchema,
|
||||
security_facets: securityFacetsSchema,
|
||||
provenance: z.array(nativeCommandInvocationSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
codeDirectoryFlagNames,
|
||||
deriveSecurityFacets,
|
||||
} from "./CodeSigningPolicy.js";
|
||||
import type { CodeDirectory } from "../domain/native/codeSigningPosture.js";
|
||||
|
||||
const directory = (
|
||||
flags: number | null,
|
||||
platform: number | null = null,
|
||||
): CodeDirectory => ({
|
||||
version: "20500",
|
||||
flags:
|
||||
flags === null
|
||||
? null
|
||||
: { value: flags, names: codeDirectoryFlagNames(flags) },
|
||||
code_slots: 1,
|
||||
special_slots: 7,
|
||||
location: "embedded",
|
||||
hash_type: "sha256",
|
||||
platform_identifier: platform,
|
||||
runtime_version: null,
|
||||
executable_segment_flags: 1,
|
||||
});
|
||||
|
||||
type PostureInput = Parameters<typeof deriveSecurityFacets>[0];
|
||||
|
||||
const derive = (
|
||||
input: Omit<PostureInput, "mixedSlices" | "appleOrigin"> &
|
||||
Partial<PostureInput>,
|
||||
) => deriveSecurityFacets({ mixedSlices: false, appleOrigin: false, ...input });
|
||||
|
||||
const states = (facets: ReturnType<typeof deriveSecurityFacets>) =>
|
||||
Object.fromEntries(facets.map(({ facet, state }) => [facet, state]));
|
||||
|
||||
const RUNTIME = 0x10000;
|
||||
|
||||
describe("CodeDirectory flags", () => {
|
||||
it("names known bits and keeps unknown bits numeric", () => {
|
||||
expect(codeDirectoryFlagNames(0x12a02)).toEqual([
|
||||
"adhoc",
|
||||
"kill",
|
||||
"restrict",
|
||||
"library-validation",
|
||||
"runtime",
|
||||
]);
|
||||
expect(codeDirectoryFlagNames(0x40000 | 0x2)).toEqual(["adhoc", "0x40000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived security facets", () => {
|
||||
it("does not apply platform policy for a reported zero platform byte", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2, 0),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "not-enforced",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
});
|
||||
});
|
||||
it("treats unsigned and plain ad hoc code as unrestricted", () => {
|
||||
for (const signed of [false, true])
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed,
|
||||
codeDirectory: signed ? directory(0x2) : null,
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "not-enforced",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "unrestricted",
|
||||
"app-sandbox": "not-sandboxed",
|
||||
});
|
||||
});
|
||||
|
||||
it("restricts hardened-runtime code unless entitlements relax it", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: { "com.apple.security.app-sandbox": true },
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
"executable-memory": "restricted",
|
||||
"app-sandbox": "sandboxed",
|
||||
});
|
||||
const relaxed = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: {
|
||||
"com.apple.security.cs.disable-library-validation": true,
|
||||
"com.apple.security.cs.allow-dyld-environment-variables": true,
|
||||
"com.apple.security.get-task-allow": true,
|
||||
"com.apple.security.cs.allow-jit": true,
|
||||
},
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(relaxed)).toMatchObject({
|
||||
"library-validation": "disabled",
|
||||
"dyld-environment-variables": "honored",
|
||||
"debugger-attach": "allowed",
|
||||
"executable-memory": "jit-allowed",
|
||||
});
|
||||
expect(relaxed[0]?.evidence).toEqual([
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets explicit flags and platform identity override entitlements", () => {
|
||||
const flagged = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | 0x800 | RUNTIME),
|
||||
entitlements: {
|
||||
"com.apple.security.cs.disable-library-validation": true,
|
||||
},
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(flagged)).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
});
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("security facets that need more than one CodeDirectory", () => {
|
||||
it("applies platform policy only to Apple-signed platform identifiers", () => {
|
||||
const crafted = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
});
|
||||
expect(states(crafted)).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
});
|
||||
expect(crafted[0]?.evidence).toEqual([
|
||||
"platform identifier",
|
||||
"Apple origin not verified",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every facet unknown when slices differ in signing state", () => {
|
||||
const mixed = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: false,
|
||||
appleOrigin: true,
|
||||
mixedSlices: true,
|
||||
});
|
||||
expect(new Set(mixed.map(({ state }) => state))).toEqual(
|
||||
new Set(["unknown"]),
|
||||
);
|
||||
expect(mixed[0]?.evidence).toEqual([
|
||||
"architecture slices differ in signing state",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves every facet unknown when verification failed", () => {
|
||||
const invalid = derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(0x2000 | RUNTIME, 26),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
appleOrigin: true,
|
||||
signatureInvalid: true,
|
||||
});
|
||||
expect(new Set(invalid.map(({ state }) => state))).toEqual(
|
||||
new Set(["unknown"]),
|
||||
);
|
||||
expect(invalid[0]?.evidence).toEqual(["signature verification failed"]);
|
||||
});
|
||||
|
||||
it("keeps facets unknown when flags or entitlements are unavailable", () => {
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(RUNTIME),
|
||||
entitlements: null,
|
||||
entitlementsKnown: false,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
"executable-memory": "unknown",
|
||||
"app-sandbox": "unknown",
|
||||
});
|
||||
expect(
|
||||
states(
|
||||
derive({
|
||||
signed: true,
|
||||
codeDirectory: directory(null),
|
||||
entitlements: null,
|
||||
entitlementsKnown: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
"executable-memory": "unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type {
|
||||
CodeDirectory,
|
||||
SecurityFacet,
|
||||
} from "../domain/native/codeSigningPosture.js";
|
||||
|
||||
/** `SecCodeSignatureFlags` bits that codesign reports in a CodeDirectory. */
|
||||
const CODE_DIRECTORY_FLAGS: readonly (readonly [number, string])[] = [
|
||||
[0x1, "host"],
|
||||
[0x2, "adhoc"],
|
||||
[0x100, "hard"],
|
||||
[0x200, "kill"],
|
||||
[0x400, "expires"],
|
||||
[0x800, "restrict"],
|
||||
[0x1000, "enforcement"],
|
||||
[0x2000, "library-validation"],
|
||||
[0x10000, "runtime"],
|
||||
[0x20000, "linker-signed"],
|
||||
];
|
||||
|
||||
/** Decode CodeDirectory flag bits; unknown bits keep their hexadecimal value. */
|
||||
export const codeDirectoryFlagNames = (value: number): string[] => {
|
||||
const flags = value >>> 0;
|
||||
const names = CODE_DIRECTORY_FLAGS.flatMap(([bit, name]) =>
|
||||
(flags & bit) === 0 ? [] : [name],
|
||||
);
|
||||
const known = CODE_DIRECTORY_FLAGS.reduce((total, [bit]) => total | bit, 0);
|
||||
for (let index = 0; index < 32; index++)
|
||||
if (((flags >>> index) & 1) === 1 && ((known >>> index) & 1) === 0)
|
||||
names.push(`0x${(2 ** index).toString(16)}`);
|
||||
return names;
|
||||
};
|
||||
|
||||
const entitlement = (
|
||||
entitlements: JsonValue | null,
|
||||
key: string,
|
||||
): boolean | undefined => {
|
||||
if (
|
||||
entitlements === null ||
|
||||
typeof entitlements !== "object" ||
|
||||
Array.isArray(entitlements)
|
||||
)
|
||||
return undefined;
|
||||
const value = entitlements[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
};
|
||||
|
||||
interface PostureInput {
|
||||
readonly signed: boolean;
|
||||
readonly codeDirectory: CodeDirectory | null;
|
||||
readonly entitlements: JsonValue | null;
|
||||
/** False when mixed or unreadable signing states hide the aggregate entitlements. */
|
||||
readonly entitlementsKnown: boolean;
|
||||
/** Architecture slices differ in signing state, so no one CodeDirectory describes the process. */
|
||||
readonly mixedSlices: boolean;
|
||||
/**
|
||||
* The signature satisfies `anchor apple`. Only then does a CodeDirectory
|
||||
* platform identifier mean an Apple platform binary.
|
||||
*/
|
||||
readonly appleOrigin: boolean;
|
||||
/**
|
||||
* The local `--deep --strict` verification failed for the opened code.
|
||||
* Flag bits from an unauthenticated CodeDirectory must not decide facets.
|
||||
*/
|
||||
readonly signatureInvalid?: boolean;
|
||||
}
|
||||
|
||||
/** Whether the CodeDirectory claims a platform binary, and whether Apple signed it. */
|
||||
const platformBinary = (
|
||||
input: PostureInput,
|
||||
): "apple" | "unverified" | "none" =>
|
||||
(input.codeDirectory?.platform_identifier ?? 0) === 0
|
||||
? "none"
|
||||
: input.appleOrigin
|
||||
? "apple"
|
||||
: "unverified";
|
||||
|
||||
const UNVERIFIED_PLATFORM = [
|
||||
"platform identifier",
|
||||
"Apple origin not verified",
|
||||
] as const;
|
||||
|
||||
const has = (input: PostureInput, flag: string): boolean =>
|
||||
input.codeDirectory?.flags?.names.includes(flag) === true;
|
||||
|
||||
const libraryValidation = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "library-validation", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"With library validation, the process loads only libraries signed by Apple or by the same team; without it, any library dyld finds can load.";
|
||||
if (!input.signed)
|
||||
return {
|
||||
...base,
|
||||
state: "not-enforced",
|
||||
evidence: ["unsigned"],
|
||||
explanation,
|
||||
};
|
||||
if (input.codeDirectory === null || input.codeDirectory.flags === null)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (has(input, "library-validation"))
|
||||
return {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["CodeDirectory flag library-validation"],
|
||||
explanation,
|
||||
};
|
||||
if (!has(input, "runtime"))
|
||||
return {
|
||||
...base,
|
||||
state: "not-enforced",
|
||||
evidence: ["no hardened runtime or library-validation flag"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(
|
||||
input.entitlements,
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
) === true
|
||||
? {
|
||||
...base,
|
||||
state: "disabled",
|
||||
evidence: [
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "enforced",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const dyldEnvironment = (input: PostureInput): SecurityFacet => {
|
||||
const base = {
|
||||
facet: "dyld-environment-variables",
|
||||
basis: "derived",
|
||||
} as const;
|
||||
const explanation =
|
||||
"Whether dyld honors DYLD_* variables such as DYLD_INSERT_LIBRARIES at launch. setuid/setgid bits, a __RESTRICT segment, and system integrity policy can also restrict them and are not evaluated here.";
|
||||
if (has(input, "restrict"))
|
||||
return {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["CodeDirectory flag restrict"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (!has(input, "runtime"))
|
||||
return input.signed && (input.codeDirectory?.flags ?? null) === null
|
||||
? {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "honored",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(
|
||||
input.entitlements,
|
||||
"com.apple.security.cs.allow-dyld-environment-variables",
|
||||
) === true
|
||||
? {
|
||||
...base,
|
||||
state: "honored",
|
||||
evidence: [
|
||||
"hardened runtime",
|
||||
"com.apple.security.cs.allow-dyld-environment-variables",
|
||||
],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "ignored",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const debuggerAttach = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "debugger-attach", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Whether a debugger running as the same user can obtain the task port. Developer Tools authorization and system integrity policy still apply.";
|
||||
if (
|
||||
input.entitlementsKnown &&
|
||||
entitlement(input.entitlements, "com.apple.security.get-task-allow") ===
|
||||
true
|
||||
)
|
||||
return {
|
||||
...base,
|
||||
state: "allowed",
|
||||
evidence: ["com.apple.security.get-task-allow"],
|
||||
explanation,
|
||||
};
|
||||
const platform = platformBinary(input);
|
||||
if (platform !== "none")
|
||||
return platform === "apple"
|
||||
? {
|
||||
...base,
|
||||
state: "blocked",
|
||||
evidence: ["platform binary"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: [...UNVERIFIED_PLATFORM],
|
||||
explanation,
|
||||
};
|
||||
if (has(input, "runtime"))
|
||||
return input.entitlementsKnown
|
||||
? {
|
||||
...base,
|
||||
state: "blocked",
|
||||
evidence: ["hardened runtime without get-task-allow"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
if (input.signed && (input.codeDirectory?.flags ?? null) === null)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
state: "allowed",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const executableMemory = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "executable-memory", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Hardened-runtime limits on writable or unsigned executable memory, relaxed by code-signing entitlements.";
|
||||
if (!has(input, "runtime"))
|
||||
return input.signed && (input.codeDirectory?.flags ?? null) === null
|
||||
? {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["CodeDirectory flags unavailable"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "unrestricted",
|
||||
evidence: ["no hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["hardened runtime", "entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
for (const [key, state] of [
|
||||
[
|
||||
"com.apple.security.cs.disable-executable-page-protection",
|
||||
"protection-disabled",
|
||||
],
|
||||
[
|
||||
"com.apple.security.cs.allow-unsigned-executable-memory",
|
||||
"unsigned-allowed",
|
||||
],
|
||||
["com.apple.security.cs.allow-jit", "jit-allowed"],
|
||||
] as const)
|
||||
if (entitlement(input.entitlements, key) === true)
|
||||
return {
|
||||
...base,
|
||||
state,
|
||||
evidence: ["hardened runtime", key],
|
||||
explanation,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
state: "restricted",
|
||||
evidence: ["hardened runtime"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
const appSandbox = (input: PostureInput): SecurityFacet => {
|
||||
const base = { facet: "app-sandbox", basis: "derived" } as const;
|
||||
const explanation =
|
||||
"Whether the App Sandbox entitlement confines the process.";
|
||||
if (!input.entitlementsKnown)
|
||||
return {
|
||||
...base,
|
||||
state: "unknown",
|
||||
evidence: ["entitlements unavailable"],
|
||||
explanation,
|
||||
};
|
||||
return entitlement(input.entitlements, "com.apple.security.app-sandbox") ===
|
||||
true
|
||||
? {
|
||||
...base,
|
||||
state: "sandboxed",
|
||||
evidence: ["com.apple.security.app-sandbox"],
|
||||
explanation,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
state: "not-sandboxed",
|
||||
evidence: ["no com.apple.security.app-sandbox entitlement"],
|
||||
explanation,
|
||||
};
|
||||
};
|
||||
|
||||
/** Derive launch-time security facets from observed flags and entitlements. */
|
||||
export const deriveSecurityFacets = (input: PostureInput): SecurityFacet[] => {
|
||||
const facets = [
|
||||
libraryValidation(input),
|
||||
dyldEnvironment(input),
|
||||
debuggerAttach(input),
|
||||
executableMemory(input),
|
||||
appSandbox(input),
|
||||
];
|
||||
// An invalid main signature leaves flag bits unauthenticated; keep the raw
|
||||
// CodeDirectory fields but report every facet as unknown.
|
||||
if (input.signatureInvalid === true)
|
||||
return facets.map((facet) => ({
|
||||
...facet,
|
||||
state: "unknown" as const,
|
||||
evidence: ["signature verification failed"],
|
||||
}));
|
||||
// Each slice runs under its own signature; the inspected one does not
|
||||
// describe an unsigned slice of the same file.
|
||||
return input.mixedSlices
|
||||
? facets.map((facet) => ({
|
||||
...facet,
|
||||
state: "unknown" as const,
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
}))
|
||||
: facets;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { InspectSignature } from "../domain/native/nativeInspection.js";
|
||||
import type { NativeCommandCapture } from "./CommandRunner.js";
|
||||
|
||||
/** Strip the diagnostic's echoed pathname for classification, keeping the raw line elsewhere. */
|
||||
export const codesignReason = (line: string, path: string): string => {
|
||||
if (line.startsWith(`${path}: `)) return line.slice(path.length + 2);
|
||||
if (line.startsWith("/") || line.startsWith(`${path}/`)) {
|
||||
const delimiter = line.lastIndexOf(": ");
|
||||
return delimiter >= 0 ? line.slice(delimiter + 2) : "";
|
||||
}
|
||||
return line;
|
||||
};
|
||||
|
||||
/** Remove the complete selected path before splitting lines, including embedded LF bytes. */
|
||||
export const codesignReasons = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): string[] => {
|
||||
const observed = `${capture.stderr}\n${capture.stdout}`;
|
||||
const text =
|
||||
path.length === 0 ? observed : observed.split(path).join("$SELECTED_PATH");
|
||||
const lines = text.split("\n");
|
||||
const progress = lines.some((line) =>
|
||||
/^--(?:prepared|validated):/u.test(line),
|
||||
);
|
||||
return (
|
||||
lines
|
||||
.filter((line) => !/^--(?:prepared|validated):/u.test(line))
|
||||
// Unprefixed continuation text can belong to an unescaped nested filename.
|
||||
// Keep it as raw evidence, but do not turn it into a verification reason.
|
||||
.filter(
|
||||
(line) =>
|
||||
!progress ||
|
||||
line.startsWith("$SELECTED_PATH: ") ||
|
||||
line.startsWith("$SELECTED_PATH/"),
|
||||
)
|
||||
.map((line) => codesignReason(line, "$SELECTED_PATH"))
|
||||
);
|
||||
};
|
||||
|
||||
/** Classify only explicit diagnostic reasons; nonzero exit alone proves no invalidity. */
|
||||
const operationalReason = (reason: string): boolean =>
|
||||
/^(?:permission denied|operation not permitted|EACCES\b|EPERM\b|I\/O error|input\/output error)/iu.test(
|
||||
reason,
|
||||
);
|
||||
|
||||
/**
|
||||
* Definitive codesign rejections. A nonzero exit whose text is not in this
|
||||
* list, and is not an operational failure, stays `unknown` so an unrecognized
|
||||
* diagnostic is not reported as a broken signature.
|
||||
*/
|
||||
const INVALID_SIGNATURE_REASONS = [
|
||||
"a resource envelope is obsolete",
|
||||
"a sealed resource is missing or invalid",
|
||||
"bundle format unrecognized, invalid, or unsuitable",
|
||||
"code or signature modified",
|
||||
"file added:",
|
||||
"file modified:",
|
||||
"invalid or unsupported format",
|
||||
"invalid signature",
|
||||
"main executable failed strict validation",
|
||||
"resource envelope is obsolete",
|
||||
"resource fork, Finder information, or similar detritus not allowed",
|
||||
"signature is invalid",
|
||||
"unsealed contents present",
|
||||
] as const;
|
||||
|
||||
const invalidSignatureReason = (reason: string): boolean => {
|
||||
const text = reason.toLowerCase();
|
||||
return INVALID_SIGNATURE_REASONS.some((phrase) =>
|
||||
text.startsWith(phrase.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
/** Operational diagnostics can coexist with an independently proven invalid component. */
|
||||
export const codesignOperationalFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): boolean => codesignReasons(capture, path).some(operationalReason);
|
||||
|
||||
const verificationStatus = (
|
||||
capture: NativeCommandCapture,
|
||||
reasons: readonly string[],
|
||||
unsigned: boolean,
|
||||
): NonNullable<InspectSignature["verification"]>["status"] => {
|
||||
if (capture.exitCode === 0) return "valid";
|
||||
const unsignedReason = reasons.some((reason) =>
|
||||
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
|
||||
reason,
|
||||
),
|
||||
);
|
||||
if (reasons.some(invalidSignatureReason)) return "invalid";
|
||||
if (unsignedReason && !unsigned) return "invalid";
|
||||
if (reasons.some(operationalReason)) return "unknown";
|
||||
if (unsignedReason) return "unsigned";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
/** Project codesign verification without changing pathname bytes or discarding diagnostics. */
|
||||
export const signatureVerification = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
unsigned: boolean,
|
||||
progressCompleteness: "complete" | "ambiguous" = "complete",
|
||||
): NonNullable<InspectSignature["verification"]> => {
|
||||
const lines = `${capture.stderr}\n${capture.stdout}`
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
const diagnostics = lines.filter(
|
||||
(line) => !/^--(?:prepared|validated):/u.test(line),
|
||||
);
|
||||
return {
|
||||
path,
|
||||
status:
|
||||
progressCompleteness === "ambiguous" && capture.exitCode !== 0
|
||||
? "unknown"
|
||||
: verificationStatus(capture, codesignReasons(capture, path), unsigned),
|
||||
exit_code: capture.exitCode,
|
||||
diagnostics,
|
||||
prepared_nested_code: [
|
||||
...new Set(
|
||||
lines.flatMap((line) =>
|
||||
line.startsWith("--prepared:")
|
||||
? [line.slice("--prepared:".length)]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
raw_stdout: capture.stdout,
|
||||
raw_stderr: capture.stderr,
|
||||
validated_nested_code: [
|
||||
...new Set(
|
||||
lines.flatMap((line) =>
|
||||
line.startsWith("--validated:")
|
||||
? [line.slice("--validated:".length)]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { observeNativeUi } from "./NativeUiObservation.js";
|
||||
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
|
||||
import { observeNativeCalls } from "./NativeCallObservation.js";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { open, realpath, stat } from "node:fs/promises";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type NativeToolName,
|
||||
} from "../contracts/native/nativeToolContracts.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -36,14 +35,9 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../domain/propertyListKeys.js";
|
||||
import {
|
||||
demangleSwiftSchema,
|
||||
inspectPlistSchema,
|
||||
inspectSignatureSchema,
|
||||
listArchitecturesSchema,
|
||||
type NativeCommandInvocation,
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
@@ -54,7 +48,8 @@ import {
|
||||
type NativeCommandCapture,
|
||||
type NativeCommandRunner,
|
||||
} from "./CommandRunner.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { inspectNativeSignature } from "./NativeSignatureInspection.js";
|
||||
import type { NativeObservation } from "./NativeObservation.js";
|
||||
import { parseDemangledSymbols } from "./parsers/demangle.js";
|
||||
import { parseLipoArchitectures } from "./parsers/lipo.js";
|
||||
import {
|
||||
@@ -208,6 +203,10 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
)
|
||||
: observation;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
// A helper that observed the abort escapes as a DOMException.
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return err(
|
||||
new AnalysisOutputError(operation, "Native output parsing failed", {
|
||||
cause,
|
||||
@@ -318,127 +317,16 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
async #inspectSignature(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<NativeObservation, AnalysisError>> {
|
||||
const display = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "--verbose=4", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!display.ok) return display;
|
||||
const unsigned = isNonzeroUnsignedObservation(display.value);
|
||||
const displayFailure = codeSignCaptureFailure(display.value);
|
||||
if (displayFailure !== null) return err(displayFailure);
|
||||
const requirements = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "-r-", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!requirements.ok) return requirements;
|
||||
const requirementsFailure = codeSignCaptureFailure(requirements.value);
|
||||
if (requirementsFailure !== null) return err(requirementsFailure);
|
||||
const entitlements = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "--entitlements", ":-", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
const entitlementsFailure = codeSignCaptureFailure(entitlements.value);
|
||||
if (entitlementsFailure !== null) return err(entitlementsFailure);
|
||||
// codesign echoes the resolved executable path in its diagnostics.
|
||||
const parsed = parseCodeSignature(display.value.stderr, unsigned, [
|
||||
this.target.path,
|
||||
...(await canonicalPath(this.target.path)),
|
||||
]);
|
||||
// The requirement is printed to stdout; stderr echoes the path.
|
||||
const requirementText =
|
||||
/designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null;
|
||||
// Entitlements XML is printed to stdout; stderr echoes the path.
|
||||
const entitlementValue = parseEntitlements(entitlements.value.stdout);
|
||||
const captures = [display.value, requirements.value, entitlements.value];
|
||||
const limitations = [
|
||||
...parsed.limitations,
|
||||
...(entitlementValue.omittedPrototypeKeys === 0
|
||||
? []
|
||||
: [
|
||||
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
|
||||
]),
|
||||
];
|
||||
const mixedSigning =
|
||||
!unsigned &&
|
||||
(isNonzeroUnsignedObservation(requirements.value) ||
|
||||
isNonzeroUnsignedObservation(entitlements.value));
|
||||
if (mixedSigning) {
|
||||
const slices = await this.#inspectMixedSignatureSlices(
|
||||
parsed.format,
|
||||
requirements.value.exitCode !== 0,
|
||||
entitlements.value.exitCode !== 0,
|
||||
signal,
|
||||
);
|
||||
if (!slices.ok) return slices;
|
||||
captures.push(...slices.value.captures);
|
||||
limitations.push(...slices.value.limitations);
|
||||
}
|
||||
const provenance = captures.map((capture) =>
|
||||
invocation(capture, this.target.path),
|
||||
);
|
||||
const result = inspectSignatureSchema.parse({
|
||||
...parsed,
|
||||
designated_requirement: requirementText,
|
||||
entitlements: entitlementValue.value,
|
||||
provenance,
|
||||
limitations,
|
||||
return inspectNativeSignature({
|
||||
target: this.target,
|
||||
capture: (argv, commandSignal) =>
|
||||
this.#run("inspect_signature", "codesign", argv, {
|
||||
signal: commandSignal,
|
||||
acceptNonZero: true,
|
||||
}),
|
||||
invocation: (capture) => invocation(capture, this.target.path),
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
return ok({
|
||||
result: jsonValueSchema.parse(result),
|
||||
provenance,
|
||||
limitations: result.limitations,
|
||||
locations: [],
|
||||
});
|
||||
}
|
||||
|
||||
async #inspectMixedSignatureSlices(
|
||||
format: string | null,
|
||||
requirementsUnavailable: boolean,
|
||||
entitlementsUnavailable: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<MixedSignatureSlices, AnalysisError>> {
|
||||
const captures: NativeCommandCapture[] = [];
|
||||
const unsigned: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
for (const architecture of codeSignArchitectures(format, this.target)) {
|
||||
const slice = await this.#run(
|
||||
"inspect_signature",
|
||||
"codesign",
|
||||
["-d", "-a", architecture, "--verbose=4", this.target.path],
|
||||
{ signal, acceptNonZero: true },
|
||||
);
|
||||
if (!slice.ok) return slice;
|
||||
captures.push(slice.value);
|
||||
if (slice.value.exitCode === 0) continue;
|
||||
if (isNonzeroUnsignedObservation(slice.value))
|
||||
unsigned.push(architecture);
|
||||
else unclassified.push(architecture);
|
||||
}
|
||||
const limitations = [
|
||||
unsigned.length > 0
|
||||
? `Unsigned Mach-O slices: ${unsigned.join(", ")}.`
|
||||
: "One or more Mach-O slices are unsigned, but their architectures could not be classified.",
|
||||
];
|
||||
if (unclassified.length > 0)
|
||||
limitations.push(
|
||||
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
|
||||
);
|
||||
if (requirementsUnavailable)
|
||||
limitations.push(
|
||||
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
if (entitlementsUnavailable)
|
||||
limitations.push(
|
||||
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
return ok({ captures, limitations });
|
||||
}
|
||||
|
||||
async #inspectPlist(
|
||||
@@ -563,18 +451,6 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
}
|
||||
}
|
||||
|
||||
interface NativeObservation {
|
||||
readonly result: JsonValue;
|
||||
readonly provenance: readonly NativeCommandInvocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
}
|
||||
|
||||
interface MixedSignatureSlices {
|
||||
readonly captures: readonly NativeCommandCapture[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const isNativeOperation = (
|
||||
operation: AnalysisOperation,
|
||||
): operation is NativeToolName =>
|
||||
@@ -595,62 +471,6 @@ const translateCommandFailure = (
|
||||
return new ProviderAdapterError(IDENTITY.id, operation, { cause: failure });
|
||||
};
|
||||
|
||||
const translateCodeSignExitFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): AnalysisError =>
|
||||
translateCommandFailure(
|
||||
"inspect_signature",
|
||||
new NativeCommandFailure("codesign", "nonzero-exit", capture.exitCode),
|
||||
);
|
||||
|
||||
const commandOutput = (capture: NativeCommandCapture): string =>
|
||||
`${capture.stdout}\n${capture.stderr}`;
|
||||
|
||||
/**
|
||||
* A signed artifact exits zero; its diagnostics can still contain this text
|
||||
* through the echoed path or identifier.
|
||||
*/
|
||||
const isNonzeroUnsignedObservation = (capture: NativeCommandCapture): boolean =>
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
/not signed at all|code object is not signed/iu.test(commandOutput(capture));
|
||||
|
||||
const canonicalPath = async (path: string): Promise<string[]> => {
|
||||
try {
|
||||
return [await realpath(path)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const codeSignCaptureFailure = (
|
||||
capture: NativeCommandCapture,
|
||||
): AnalysisError | null =>
|
||||
capture.exitCode === 0 || isNonzeroUnsignedObservation(capture)
|
||||
? null
|
||||
: translateCodeSignExitFailure(capture);
|
||||
|
||||
const codeSignArchitectures = (
|
||||
format: string | null,
|
||||
target: BinaryTarget,
|
||||
): string[] => {
|
||||
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
|
||||
format ?? "",
|
||||
)?.[1];
|
||||
if (universal !== undefined) {
|
||||
const architectures = universal
|
||||
.split(/\s+/u)
|
||||
.filter((architecture) =>
|
||||
/^(?:i386|x86_64|armv7|arm64|arm64e)$/u.test(architecture),
|
||||
);
|
||||
if (architectures.length > 0) return [...new Set(architectures)];
|
||||
}
|
||||
if (target.kind !== "executable" || target.format !== "mach-o") return [];
|
||||
return target.availableArchitectures.map((architecture) =>
|
||||
architecture === "x86" ? "i386" : architecture,
|
||||
);
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
capture: NativeCommandCapture,
|
||||
artifactPath: string,
|
||||
@@ -671,18 +491,6 @@ const invocation = (
|
||||
stderr_bytes: capture.stderrBytes,
|
||||
});
|
||||
|
||||
const parseEntitlements = (
|
||||
output: string,
|
||||
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
|
||||
const start = output.indexOf("<?xml");
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
|
||||
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
|
||||
output.slice(start, end + "</plist>".length),
|
||||
);
|
||||
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a plist that cannot be decoded as the caller's selection, or as the
|
||||
* target's missing default, rather than as a failed tool run.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
|
||||
|
||||
it("observes cancellation between nested filesystem lookups", async () => {
|
||||
const controller = new AbortController();
|
||||
const paths: string[] = [];
|
||||
await expect(
|
||||
inspectNestedCodePaths(
|
||||
{
|
||||
validated_nested_code: ["/app/first", "/app/second"],
|
||||
},
|
||||
controller.signal,
|
||||
{
|
||||
lookup: async (path) => {
|
||||
paths.push(path);
|
||||
controller.abort();
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(paths).toEqual(["/app/first"]);
|
||||
});
|
||||
|
||||
it("checks prepared-only fragments with the same completeness rules", async () => {
|
||||
const warnings = await inspectNestedCodePaths(
|
||||
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("not found"), { code: "ENOENT" });
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
);
|
||||
expect(warnings.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
'prepared nested code at "/app/odd", which does not exist',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["EACCES", "EPERM", "EIO", "ESTALE"])(
|
||||
"keeps observed records when path lookup fails with %s",
|
||||
async (code) => {
|
||||
const result = await inspectNestedCodePaths(
|
||||
{
|
||||
prepared_nested_code: ["/app/first"],
|
||||
validated_nested_code: ["/app/second"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("lookup failed"), { code });
|
||||
},
|
||||
names: async () => [],
|
||||
},
|
||||
);
|
||||
expect(result.limitations).toHaveLength(2);
|
||||
expect(result.limitations.every((message) => message.includes(code))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.progressAmbiguous).toBe(true);
|
||||
},
|
||||
);
|
||||
it("detects newline ambiguity even when the reported first fragment does not exist", async () => {
|
||||
const result = await inspectNestedCodePaths(
|
||||
{ prepared_nested_code: ["/app/odd"], validated_nested_code: [] },
|
||||
undefined,
|
||||
{
|
||||
lookup: async () => {
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
},
|
||||
names: async () => ["odd\n/app: invalid signature"],
|
||||
},
|
||||
);
|
||||
expect(result.progressAmbiguous).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { dirname, basename } from "node:path";
|
||||
|
||||
/** Filesystem seam for path-completeness checks and cancellation regressions. */
|
||||
export interface NativeNestedPathView {
|
||||
lookup(path: string): Promise<void>;
|
||||
names(directory: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
const FILE_SYSTEM: NativeNestedPathView = {
|
||||
lookup: async (path) => {
|
||||
await lstat(path);
|
||||
},
|
||||
names: (path) => readdir(path),
|
||||
};
|
||||
|
||||
const errorCode = (cause: unknown) =>
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
|
||||
/** Completeness of newline-delimited progress records, distinct from verification. */
|
||||
export interface NestedCodePathInspection {
|
||||
readonly limitations: readonly string[];
|
||||
readonly progressAmbiguous: boolean;
|
||||
}
|
||||
|
||||
/** Preserve reported paths and operational errors, and identify ambiguous progress. */
|
||||
export const inspectNestedCodePaths = async (
|
||||
verification: {
|
||||
readonly validated_nested_code: readonly string[];
|
||||
readonly prepared_nested_code?: readonly string[];
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
view: NativeNestedPathView = FILE_SYSTEM,
|
||||
): Promise<NestedCodePathInspection> => {
|
||||
signal?.throwIfAborted();
|
||||
const limitations: string[] = [];
|
||||
const directories = new Map<string, Promise<ReadonlySet<string>>>();
|
||||
let progressAmbiguous = false;
|
||||
const records = new Map<string, "prepared" | "validated">();
|
||||
for (const path of verification.prepared_nested_code ?? [])
|
||||
records.set(path, "prepared");
|
||||
for (const path of verification.validated_nested_code)
|
||||
records.set(path, "validated");
|
||||
for (const [path, record] of records) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
await view.lookup(path);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const code = errorCode(cause);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: permission denied (${code}); the reported path may be a fragment.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (code === "ENOENT" || code === "ENOTDIR")
|
||||
limitations.push(
|
||||
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which does not exist; structured nested-code records may hold fragments of a path containing a newline.`,
|
||||
);
|
||||
else {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)}: ${cause instanceof Error ? cause.message : String(cause)} (${String(code ?? "unknown")}); the reported path may be a fragment.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const parent = dirname(path);
|
||||
let names = directories.get(parent);
|
||||
if (names === undefined) {
|
||||
names = view
|
||||
.names(parent)
|
||||
.then(
|
||||
(entries) =>
|
||||
new Set(
|
||||
entries
|
||||
.filter((name) => name.includes("\n"))
|
||||
.map((name) => name.slice(0, name.indexOf("\n"))),
|
||||
),
|
||||
);
|
||||
directories.set(parent, names);
|
||||
}
|
||||
try {
|
||||
const prefixes = await names;
|
||||
const ambiguous = prefixes.has(basename(path));
|
||||
signal?.throwIfAborted();
|
||||
if (ambiguous) {
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`codesign reported ${record} nested code at ${JSON.stringify(path)}, which could be the first line of a nested path containing a newline; structured nested-code records are ambiguous and should not be trusted as complete.`,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
progressAmbiguous = true;
|
||||
limitations.push(
|
||||
`Could not confirm completeness of codesign nested path ${JSON.stringify(path)} from parent directory ${JSON.stringify(parent)}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { limitations, progressAmbiguous };
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import type { NativeCommandInvocation } from "../domain/native/nativeInspection.js";
|
||||
|
||||
/** Provider observation before shared execution/Evidence projection. */
|
||||
export interface NativeObservation {
|
||||
readonly result: JsonValue;
|
||||
readonly provenance: readonly NativeCommandInvocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
/** Parse the XML entitlement document within codesign output, retaining omission evidence. */
|
||||
export const parseSignatureEntitlements = (
|
||||
output: string,
|
||||
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
|
||||
const start = output.indexOf("<?xml");
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start) return { value: null, omittedPrototypeKeys: 0 };
|
||||
const { value, omittedPrototypeKeys } = parseXmlPropertyList(
|
||||
output.slice(start, end + "</plist>".length),
|
||||
);
|
||||
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
inspectSignatureSchema,
|
||||
type NativeCommandInvocation,
|
||||
} from "../domain/native/nativeInspection.js";
|
||||
import { deriveSecurityFacets } from "./CodeSigningPolicy.js";
|
||||
import {
|
||||
bindSignatureTarget,
|
||||
verifySignatureTarget,
|
||||
} from "./SignatureTargetBinding.js";
|
||||
import { omittedPrototypeKeysLimitation } from "../domain/propertyListKeys.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
NativeCommandFailure,
|
||||
type NativeCommandCapture,
|
||||
} from "./CommandRunner.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
import type { NativeObservation } from "./NativeObservation.js";
|
||||
import {
|
||||
signatureVerification,
|
||||
codesignOperationalFailure,
|
||||
codesignReasons,
|
||||
} from "./CodesignVerification.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
|
||||
import {
|
||||
inspectSignatureSlices,
|
||||
unsignedSignatureCapture,
|
||||
type SignatureCapture,
|
||||
} from "./NativeSignatureProbes.js";
|
||||
import {
|
||||
SIGNATURE_POSTURE_LIMITATIONS,
|
||||
signedCodePath,
|
||||
stapledTicket,
|
||||
} from "./NativeSignaturePosture.js";
|
||||
import { inspectNestedCodePaths } from "./NativeNestedCode.js";
|
||||
|
||||
interface SignatureDisplays {
|
||||
readonly display: NativeCommandCapture;
|
||||
readonly requirements: NativeCommandCapture;
|
||||
readonly entitlements: NativeCommandCapture;
|
||||
}
|
||||
|
||||
/** Display failures and unsigned observations use the same reason parser as verification. */
|
||||
const signatureDisplays = async (
|
||||
path: string,
|
||||
capture: SignatureCapture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureDisplays, AnalysisError>> => {
|
||||
const display = await capture(["-d", "--verbose=4", path], signal);
|
||||
if (!display.ok) return display;
|
||||
const requirements = await capture(["-d", "-r-", path], signal);
|
||||
if (!requirements.ok) return requirements;
|
||||
const entitlements = await capture(
|
||||
["-d", "--entitlements", ":-", path],
|
||||
signal,
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
for (const result of [
|
||||
display.value,
|
||||
requirements.value,
|
||||
entitlements.value,
|
||||
]) {
|
||||
if (result.exitCode !== 0 && !unsignedSignatureCapture(result, path))
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
"inspect_signature",
|
||||
{
|
||||
diagnostics: {
|
||||
path,
|
||||
tool: result.tool,
|
||||
arguments: [...result.arguments],
|
||||
exit_code: result.exitCode,
|
||||
signal: result.signal,
|
||||
raw_stdout: result.stdout,
|
||||
raw_stderr: result.stderr,
|
||||
},
|
||||
cause: new NativeCommandFailure(
|
||||
"codesign",
|
||||
"nonzero-exit",
|
||||
result.exitCode,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
display: display.value,
|
||||
requirements: requirements.value,
|
||||
entitlements: entitlements.value,
|
||||
});
|
||||
};
|
||||
|
||||
/** Compose displays, independent slice knowledge, verification, and local ticket evidence. */
|
||||
export const inspectNativeSignature = async (options: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly capture: SignatureCapture;
|
||||
readonly invocation: (
|
||||
capture: NativeCommandCapture,
|
||||
) => NativeCommandInvocation;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<Result<NativeObservation, AnalysisError>> => {
|
||||
const { target, capture, signal } = options;
|
||||
const binding = await bindSignatureTarget(target, signal);
|
||||
if (!binding.ok) return binding;
|
||||
const displays = await signatureDisplays(target.path, capture, signal);
|
||||
if (!displays.ok) return displays;
|
||||
const { display, requirements, entitlements } = displays.value;
|
||||
const unsigned = unsignedSignatureCapture(display, target.path);
|
||||
let canonical: string[] = [];
|
||||
try {
|
||||
canonical = [await realpath(target.path)];
|
||||
} catch {
|
||||
/* The display retains the observed path. */
|
||||
}
|
||||
const parsed = parseCodeSignature(display.stderr, unsigned, [
|
||||
target.path,
|
||||
...canonical,
|
||||
]);
|
||||
const entitlementValue = parseSignatureEntitlements(entitlements.stdout);
|
||||
const code = signedCodePath(target);
|
||||
const verify = (path: string) =>
|
||||
capture(["--verify", "--deep", "--strict", "--verbose=2", path], signal);
|
||||
const verified = await verify(code);
|
||||
if (!verified.ok) return verified;
|
||||
const captures = [display, requirements, entitlements, verified.value];
|
||||
let appleOrigin = false;
|
||||
let appleOriginFailure: string | undefined;
|
||||
if (!unsigned && (parsed.code_directory?.platform_identifier ?? 0) !== 0) {
|
||||
const anchor = await capture(["--verify", "-R=anchor apple", code], signal);
|
||||
if (!anchor.ok) return anchor;
|
||||
captures.push(anchor.value);
|
||||
appleOrigin = anchor.value.exitCode === 0;
|
||||
if (!appleOrigin) {
|
||||
const reason = codesignReasons(anchor.value, code)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join("\n");
|
||||
appleOriginFailure = reason || `codesign exited ${anchor.value.exitCode}`;
|
||||
}
|
||||
}
|
||||
const slices = await inspectSignatureSlices(
|
||||
target,
|
||||
{ format: parsed.format, requirements, entitlements, unsigned },
|
||||
capture,
|
||||
signal,
|
||||
);
|
||||
if (!slices.ok) return slices;
|
||||
captures.push(...slices.value.captures);
|
||||
const aggregateUnsigned =
|
||||
slices.value.signed === undefined ? unsigned : !slices.value.signed;
|
||||
// Verification is independent evidence: uncertain display probes must not
|
||||
// erase an explicit signature failure from the verifier.
|
||||
const reportedVerification = signatureVerification(
|
||||
verified.value,
|
||||
code,
|
||||
aggregateUnsigned,
|
||||
);
|
||||
const nestedPaths = await inspectNestedCodePaths(
|
||||
reportedVerification,
|
||||
signal,
|
||||
);
|
||||
const verification = nestedPaths.progressAmbiguous
|
||||
? signatureVerification(
|
||||
verified.value,
|
||||
code,
|
||||
aggregateUnsigned,
|
||||
"ambiguous",
|
||||
)
|
||||
: reportedVerification;
|
||||
const limitations = [
|
||||
...parsed.limitations,
|
||||
...SIGNATURE_POSTURE_LIMITATIONS,
|
||||
...slices.value.limitations,
|
||||
...nestedPaths.limitations,
|
||||
];
|
||||
if (entitlementValue.omittedPrototypeKeys !== 0)
|
||||
limitations.push(
|
||||
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
|
||||
);
|
||||
if (appleOriginFailure !== undefined)
|
||||
limitations.push(`Apple-origin check failed: ${appleOriginFailure}`);
|
||||
if (verification.status === "unknown")
|
||||
limitations.push(
|
||||
"Signature verification is inconclusive (permission, I/O or unrecognized diagnostic); this is not a proven broken signature.",
|
||||
);
|
||||
else if (codesignOperationalFailure(verified.value, code))
|
||||
limitations.push(
|
||||
"Some code could not be verified due to operational diagnostics; definitive signature failures elsewhere still prove invalidity.",
|
||||
);
|
||||
let mainInvalid =
|
||||
verification.status === "invalid" || verification.status === "unknown";
|
||||
if (mainInvalid && !unsigned && code !== target.path) {
|
||||
const main = await verify(target.path);
|
||||
if (!main.ok) return main;
|
||||
captures.push(main.value);
|
||||
mainInvalid = main.value.exitCode !== 0;
|
||||
}
|
||||
const provenance = captures.map(options.invocation);
|
||||
const ticket = await stapledTicket(target, signal);
|
||||
const version = await verifySignatureTarget(target, binding.value, signal);
|
||||
if (!version.ok) return version;
|
||||
const facets = deriveSecurityFacets({
|
||||
signed: !aggregateUnsigned,
|
||||
codeDirectory: parsed.code_directory,
|
||||
entitlements: entitlementValue.value,
|
||||
entitlementsKnown: unsigned || entitlements.exitCode === 0,
|
||||
mixedSlices: slices.value.mixed,
|
||||
appleOrigin,
|
||||
signatureInvalid: mainInvalid,
|
||||
});
|
||||
const result = inspectSignatureSchema.parse({
|
||||
...parsed,
|
||||
signed: !aggregateUnsigned,
|
||||
designated_requirement:
|
||||
/designated\s*=>\s*(.+)$/mu.exec(requirements.stdout)?.[1] ?? null,
|
||||
entitlements: entitlementValue.value,
|
||||
verification,
|
||||
stapled_ticket: ticket,
|
||||
security_facets: facets,
|
||||
provenance,
|
||||
limitations,
|
||||
});
|
||||
return ok({
|
||||
result: jsonValueSchema.parse(result),
|
||||
provenance,
|
||||
limitations: result.limitations,
|
||||
locations: [],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { InspectSignature } from "../domain/native/nativeInspection.js";
|
||||
import { ticketStructureIssue } from "./NotarizationTicket.js";
|
||||
|
||||
/** Notarization tickets are a few kilobytes; larger files are hashed in chunks. */
|
||||
const HASH_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
/** The code `codesign --verify` should check: the bundle when one was opened. */
|
||||
export const signedCodePath = (target: {
|
||||
readonly path: string;
|
||||
readonly sourcePath?: string;
|
||||
readonly bundleInfoPlist?: string;
|
||||
}): string => appBundle(target) ?? target.path;
|
||||
|
||||
/**
|
||||
* The app bundle directory a target was opened from. Only a bundle opened as a
|
||||
* directory carries its Info.plist; a regular file named `X.app` is not one.
|
||||
* The bundle is derived from the Info.plist location, not from the outer
|
||||
* opening path, so an iOS-on-Mac wrapper resolves to the inner `Wrapper/*.app`
|
||||
* bundle whose executable and ticket were actually inspected.
|
||||
*/
|
||||
const appBundle = (target: {
|
||||
readonly sourcePath?: string;
|
||||
readonly bundleInfoPlist?: string;
|
||||
}): string | undefined => {
|
||||
if (target.bundleInfoPlist === undefined) return undefined;
|
||||
const derived = bundleDirFromPlist(target.bundleInfoPlist);
|
||||
if (derived !== undefined) return derived;
|
||||
return target.sourcePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle directory containing an Info.plist: `.../Foo.app/Contents/Info.plist`
|
||||
* lives two levels below its bundle, while a flat `.../Foo.app/Info.plist`
|
||||
* lives one level below it.
|
||||
*/
|
||||
const bundleDirFromPlist = (plist: string): string | undefined => {
|
||||
const contentsSuffix = "/Contents/Info.plist";
|
||||
if (plist.endsWith(contentsSuffix))
|
||||
return plist.slice(0, -"/Contents/Info.plist".length) || undefined;
|
||||
const flatSuffix = "/Info.plist";
|
||||
if (plist.endsWith(flatSuffix))
|
||||
return plist.slice(0, -"/Info.plist".length) || undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a ticket stapled to an app bundle, which `stapler` stores at
|
||||
* `Contents/CodeResources`. Presence is observed; the ticket's validity and
|
||||
* Apple's notarization record are not checked, because that needs the network.
|
||||
*/
|
||||
export const stapledTicket = async (
|
||||
target: { readonly sourcePath?: string; readonly bundleInfoPlist?: string },
|
||||
signal?: AbortSignal,
|
||||
pathProbe: (path: string, phase: "before" | "after") => Promise<Stats> = (
|
||||
path,
|
||||
) => lstat(path),
|
||||
): Promise<InspectSignature["stapled_ticket"]> => {
|
||||
signal?.throwIfAborted();
|
||||
const bundle = appBundle(target);
|
||||
if (bundle === undefined) {
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
status: "not-applicable",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
const relative = "Contents/CodeResources";
|
||||
const path = join(bundle, relative);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const metadata = await pathProbe(path, "before");
|
||||
if (!metadata.isFile()) {
|
||||
signal?.throwIfAborted();
|
||||
return absent;
|
||||
}
|
||||
// Open nonblocking so a concurrent replacement with a FIFO cannot hang
|
||||
// inspect_signature; the opened handle is revalidated below.
|
||||
const handle = await open(
|
||||
path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
// The path was a regular file at lstat. A directory or FIFO now means
|
||||
// the ticket was replaced; absence would hide that change.
|
||||
if (!opened.isFile())
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
// If the path was replaced between lstat and open, or mutated while
|
||||
// hashing, the digest would describe the wrong bytes: dev and ino catch
|
||||
// replacement, while size and mtime catch same-inode writes. Report any
|
||||
// drift as changed instead of a stale digest.
|
||||
if (!sameTicketFile(opened, metadata))
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
const structureIssue = await ticketStructureIssue(
|
||||
async (offset, length) => {
|
||||
signal?.throwIfAborted();
|
||||
const buffer = new Uint8Array(length);
|
||||
let done = 0;
|
||||
while (done < length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
done,
|
||||
length - done,
|
||||
offset + done,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
done += bytesRead;
|
||||
}
|
||||
return buffer.subarray(0, done);
|
||||
},
|
||||
opened.size,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
|
||||
let size = 0;
|
||||
while (size < opened.size) {
|
||||
signal?.throwIfAborted();
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, opened.size - size),
|
||||
size,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
size += bytesRead;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const closing = await handle.stat();
|
||||
if (!sameTicketFile(closing, opened) || size !== opened.size)
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
// An atomic replacement swaps the pathname to a new inode while the
|
||||
// handle still describes the unlinked original: re-resolve the path and
|
||||
// refuse a digest that no longer belongs to the bundle.
|
||||
try {
|
||||
const current = await pathProbe(path, "after");
|
||||
if (!sameTicketFile(current, opened))
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const code = errorCode(cause);
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") throw cause;
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: "changed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: structureIssue === null ? "present" : "unreadable",
|
||||
path: relative,
|
||||
sha256: hash.digest("hex"),
|
||||
size,
|
||||
reason: structureIssue,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
const code = errorCode(cause);
|
||||
if (code === "ENOENT" || code === "ENOTDIR") return absent;
|
||||
// A privacy or ACL denial leaves the ticket's presence unknown, not the
|
||||
// whole signature inspection.
|
||||
if (code === "EACCES" || code === "EPERM" || code === "EIO")
|
||||
return {
|
||||
status: "unreadable",
|
||||
path: relative,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: code,
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether two observations describe the same file version. Device and inode
|
||||
* catch replacement; size and mtime catch same-inode writes and truncations
|
||||
* whose digest would otherwise mix multiple file states.
|
||||
*/
|
||||
const sameTicketFile = (
|
||||
left: {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
readonly ctimeMs: number;
|
||||
},
|
||||
right: {
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
readonly ctimeMs: number;
|
||||
},
|
||||
): boolean =>
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs;
|
||||
|
||||
const absent = {
|
||||
status: "absent",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
} as const;
|
||||
|
||||
const errorCode = (cause: unknown): string | undefined =>
|
||||
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
||||
? cause.code
|
||||
: undefined;
|
||||
|
||||
/** Limitations that keep verification and notarization claims bounded. */
|
||||
export const SIGNATURE_POSTURE_LIMITATIONS = [
|
||||
"Signature verification is local `codesign --verify --deep --strict`; certificate revocation, Gatekeeper policy, and Apple's notarization records are not checked.",
|
||||
"Security facets are derived from CodeDirectory flags and entitlements; runtime policy such as System Integrity Protection, AMFI, and setuid bits can further restrict a process.",
|
||||
"Ticket presence requires recognized local s8ch/g8tk container and DER framing; certificate authenticity, ticket signature validity, and ticket-to-code binding are not verified.",
|
||||
];
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { canonicalJson } from "../domain/comparisonSemantics.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { NativeCommandCapture } from "./CommandRunner.js";
|
||||
import { codesignReasons } from "./CodesignVerification.js";
|
||||
import { parseCodeSignature } from "./parsers/codesign.js";
|
||||
import { parseSignatureEntitlements } from "./NativeSignatureEntitlements.js";
|
||||
import {
|
||||
signatureArchitectures,
|
||||
summarizeSignatureSlices,
|
||||
} from "./NativeSignatureSlices.js";
|
||||
|
||||
/** The provider owns process execution and typed failure translation. */
|
||||
export type SignatureCapture = (
|
||||
argv: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
) => Promise<Result<NativeCommandCapture, AnalysisError>>;
|
||||
|
||||
/** Detect explicit unsigned display reasons, not words embedded in echoed pathnames. */
|
||||
export const unsignedSignatureCapture = (
|
||||
capture: NativeCommandCapture,
|
||||
path: string,
|
||||
): boolean =>
|
||||
capture.exitCode !== null &&
|
||||
capture.exitCode !== 0 &&
|
||||
codesignReasons(capture, path).some((reason) =>
|
||||
/^(?:code object is not signed at all|code object is not signed|not signed at all)/iu.test(
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
/** Slice observations and aggregate knowledge, with every contributing capture retained. */
|
||||
export interface SignatureSliceObservation {
|
||||
readonly mixed: boolean;
|
||||
readonly captures: readonly NativeCommandCapture[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly signed: boolean | undefined;
|
||||
readonly classificationUnknown: boolean;
|
||||
}
|
||||
|
||||
/** Proactively inspect independent signatures of universal Mach-O architectures. */
|
||||
export const inspectSignatureSlices = async (
|
||||
target: BinaryTarget,
|
||||
input: {
|
||||
readonly format: string | null;
|
||||
readonly requirements: NativeCommandCapture;
|
||||
readonly entitlements: NativeCommandCapture;
|
||||
readonly unsigned: boolean;
|
||||
},
|
||||
capture: SignatureCapture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureSliceObservation, AnalysisError>> => {
|
||||
const architectures = signatureArchitectures(input.format, target);
|
||||
const gate =
|
||||
!input.unsigned &&
|
||||
(unsignedSignatureCapture(input.requirements, target.path) ||
|
||||
unsignedSignatureCapture(input.entitlements, target.path));
|
||||
if (!gate && architectures.length <= 1)
|
||||
return ok({
|
||||
mixed: false,
|
||||
captures: [],
|
||||
limitations: [],
|
||||
signed: undefined,
|
||||
classificationUnknown: false,
|
||||
});
|
||||
const captures: NativeCommandCapture[] = [];
|
||||
const signed: string[] = [];
|
||||
const unsigned: string[] = [];
|
||||
const unclassified: string[] = [];
|
||||
const postures = new Map<string, string>();
|
||||
for (const architecture of architectures) {
|
||||
const display = await capture(
|
||||
["-d", "-a", architecture, "--verbose=4", target.path],
|
||||
signal,
|
||||
);
|
||||
if (!display.ok) return display;
|
||||
captures.push(display.value);
|
||||
if (display.value.exitCode !== 0) {
|
||||
(unsignedSignatureCapture(display.value, target.path)
|
||||
? unsigned
|
||||
: unclassified
|
||||
).push(architecture);
|
||||
continue;
|
||||
}
|
||||
signed.push(architecture);
|
||||
const entitlements = await capture(
|
||||
["-d", "--entitlements", ":-", "-a", architecture, target.path],
|
||||
signal,
|
||||
);
|
||||
if (!entitlements.ok) return entitlements;
|
||||
captures.push(entitlements.value);
|
||||
const directory = parseCodeSignature(display.value.stderr, false, [
|
||||
target.path,
|
||||
]).code_directory;
|
||||
if (
|
||||
directory?.flags === null ||
|
||||
directory === null ||
|
||||
entitlements.value.exitCode !== 0
|
||||
) {
|
||||
unclassified.push(architecture);
|
||||
continue;
|
||||
}
|
||||
postures.set(
|
||||
architecture,
|
||||
canonicalJson(
|
||||
{
|
||||
flags: directory.flags.value,
|
||||
platform: directory.platform_identifier,
|
||||
entitlements: parseSignatureEntitlements(entitlements.value.stdout)
|
||||
.value,
|
||||
},
|
||||
"Signature slice posture",
|
||||
),
|
||||
);
|
||||
}
|
||||
const summary = summarizeSignatureSlices({
|
||||
signed,
|
||||
unsigned,
|
||||
unclassified,
|
||||
postures,
|
||||
});
|
||||
const limitations: string[] = [];
|
||||
if (unsigned.length > 0)
|
||||
limitations.push(`Unsigned Mach-O slices: ${unsigned.join(", ")}.`);
|
||||
if (summary.postureDiffers)
|
||||
limitations.push(
|
||||
"Signed Mach-O slices differ in CodeDirectory flags or entitlements; aggregate facets are unknown.",
|
||||
);
|
||||
if (unclassified.length > 0)
|
||||
limitations.push(
|
||||
`Signature state could not be classified for Mach-O slices: ${unclassified.join(", ")}.`,
|
||||
);
|
||||
if (gate && input.requirements.exitCode !== 0)
|
||||
limitations.push(
|
||||
"The aggregate designated requirement is unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
if (gate && input.entitlements.exitCode !== 0)
|
||||
limitations.push(
|
||||
"The aggregate entitlements are unavailable because Mach-O slices have mixed signing states.",
|
||||
);
|
||||
return ok({
|
||||
...summary,
|
||||
mixed: gate || summary.mixed,
|
||||
captures,
|
||||
limitations,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
|
||||
/** Architectures to probe, retaining subtypes from codesign and target inventory. */
|
||||
export const signatureArchitectures = (
|
||||
format: string | null,
|
||||
target: BinaryTarget,
|
||||
): string[] => {
|
||||
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
|
||||
format ?? "",
|
||||
)?.[1];
|
||||
const declared =
|
||||
universal?.split(/\s+/u).filter((name) => name.length > 0) ?? [];
|
||||
const available =
|
||||
target.kind === "executable" && target.format === "mach-o"
|
||||
? target.availableArchitectures.map((name) =>
|
||||
name === "x86" ? "i386" : name,
|
||||
)
|
||||
: [];
|
||||
return [...new Set([...declared, ...available])];
|
||||
};
|
||||
|
||||
/** Aggregate signing knowledge, keeping inconclusive probes distinct from unsigned. */
|
||||
export const summarizeSignatureSlices = (input: {
|
||||
readonly signed: readonly string[];
|
||||
readonly unsigned: readonly string[];
|
||||
readonly unclassified: readonly string[];
|
||||
readonly postures: ReadonlyMap<string, string>;
|
||||
}): {
|
||||
readonly signed: boolean | undefined;
|
||||
readonly mixed: boolean;
|
||||
readonly postureDiffers: boolean;
|
||||
readonly classificationUnknown: boolean;
|
||||
} => {
|
||||
const classificationUnknown = input.unclassified.length > 0;
|
||||
const postureDiffers = new Set(input.postures.values()).size > 1;
|
||||
const signed =
|
||||
input.signed.length > 0
|
||||
? true
|
||||
: classificationUnknown || input.unsigned.length === 0
|
||||
? undefined
|
||||
: false;
|
||||
return {
|
||||
signed,
|
||||
classificationUnknown,
|
||||
postureDiffers,
|
||||
mixed:
|
||||
classificationUnknown ||
|
||||
(input.signed.length > 0 && input.unsigned.length > 0) ||
|
||||
postureDiffers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { notarizationTicketFixture } from "../../tests/fixtures/notarizationTicket.js";
|
||||
import { ticketStructureIssue } from "./NotarizationTicket.js";
|
||||
|
||||
const inspect = (bytes: Uint8Array) =>
|
||||
ticketStructureIssue(
|
||||
async (offset, length) => bytes.subarray(offset, offset + length),
|
||||
bytes.length,
|
||||
);
|
||||
|
||||
it("recognizes local ticket framing without claiming cryptographic validity", async () => {
|
||||
expect(await inspect(notarizationTicketFixture())).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty, arbitrary, truncated and contradictory ticket containers", async () => {
|
||||
expect(await inspect(new Uint8Array())).toContain("Not a recognized");
|
||||
expect(await inspect(Buffer.from("stapled ticket bytes"))).toContain(
|
||||
"Not a recognized",
|
||||
);
|
||||
const ticket = Buffer.from(notarizationTicketFixture());
|
||||
expect(await inspect(ticket.subarray(0, ticket.length - 1))).toContain(
|
||||
"signature",
|
||||
);
|
||||
ticket.writeUInt32LE(2, 16 + 4 + 8);
|
||||
expect(await inspect(ticket)).toContain("hash records disagree");
|
||||
});
|
||||
|
||||
it("requires the complete signature field and validates all padding bytes", async () => {
|
||||
const bytes = Buffer.from(notarizationTicketFixture());
|
||||
expect(await inspect(bytes.subarray(0, bytes.length - 64))).toContain(
|
||||
"72 bytes",
|
||||
);
|
||||
expect(await inspect(bytes)).toBeNull();
|
||||
bytes[bytes.length - 1] = 1;
|
||||
expect(await inspect(bytes)).toContain("signature padding");
|
||||
expect(await inspect(Buffer.concat([bytes, Buffer.alloc(1)]))).toContain(
|
||||
"72 bytes",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Bounded positional reader used to inspect local ticket framing. */
|
||||
export type TicketReadAt = (
|
||||
offset: number,
|
||||
length: number,
|
||||
) => Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Inspect the local s8ch/g8tk ticket framing. Observed in stapled app tickets;
|
||||
* see https://www.mothersruin.com/software/Archaeology/reverse/tickets.html.
|
||||
* This does not authenticate certificates, signatures, or notarized cdhashes.
|
||||
*/
|
||||
export const ticketStructureIssue = async (
|
||||
read: TicketReadAt,
|
||||
size: number,
|
||||
): Promise<string | null> => {
|
||||
const header = Buffer.from(await read(0, 16));
|
||||
if (header.length !== 16 || header.toString("ascii", 0, 4) !== "s8ch")
|
||||
return "Not a recognized s8ch notarization ticket container";
|
||||
if (header.readUInt32LE(4) !== 1)
|
||||
return `Unsupported notarization ticket container version ${header.readUInt32LE(4)}`;
|
||||
const certificateSize = header.readUInt32LE(8);
|
||||
const contentSize = header.readUInt32LE(12);
|
||||
const contentOffset = 16 + certificateSize;
|
||||
const signatureOffset = contentOffset + contentSize;
|
||||
if (certificateSize < 4 || contentSize < 24 || signatureOffset >= size)
|
||||
return "Ticket certificate, content, or signature extent is missing or truncated";
|
||||
const content = Buffer.from(await read(contentOffset, 24));
|
||||
if (content.length !== 24 || content.toString("ascii", 0, 4) !== "g8tk")
|
||||
return "Ticket content has no complete g8tk header";
|
||||
const hashSize = content.readUInt16LE(6);
|
||||
const hashCount = content.readUInt32LE(8);
|
||||
if (
|
||||
hashSize === 0 ||
|
||||
hashCount === 0 ||
|
||||
24 + (hashSize + 1) * hashCount !== contentSize
|
||||
)
|
||||
return "Ticket hash records disagree with the declared content length";
|
||||
const certificates = await derSequenceExtent(read, 16, certificateSize);
|
||||
if (certificates !== certificateSize)
|
||||
return "Ticket certificate block has malformed DER framing";
|
||||
const signatureFieldSize = 72;
|
||||
if (size - signatureOffset !== signatureFieldSize)
|
||||
return "Ticket signature field is truncated or does not contain exactly 72 bytes";
|
||||
const signatureSize = await derSequenceExtent(
|
||||
read,
|
||||
signatureOffset,
|
||||
signatureFieldSize,
|
||||
);
|
||||
if (signatureSize === null || signatureSize < 8)
|
||||
return "Ticket signature has malformed DER framing";
|
||||
// The fixed signature field contains DER followed by zero padding.
|
||||
const paddingSize = size - signatureOffset - signatureSize;
|
||||
const padding = await read(signatureOffset + signatureSize, paddingSize);
|
||||
if (padding.byteLength !== paddingSize || padding.some((byte) => byte !== 0))
|
||||
return "Ticket has malformed signature padding";
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Read only a DER SEQUENCE tag/length and check its extent against its section. */
|
||||
const derSequenceExtent = async (
|
||||
read: TicketReadAt,
|
||||
offset: number,
|
||||
available: number,
|
||||
): Promise<number | null> => {
|
||||
const prefix = Buffer.from(await read(offset, Math.min(6, available)));
|
||||
if (prefix.length < 2 || prefix[0] !== 0x30) return null;
|
||||
const first = prefix[1] ?? 0;
|
||||
if (first < 128) return first + 2 <= available ? first + 2 : null;
|
||||
const width = first & 0x7f;
|
||||
if (width === 0 || width > 4 || prefix.length < width + 2 || prefix[2] === 0)
|
||||
return null;
|
||||
let length = 0;
|
||||
for (let index = 0; index < width; index++)
|
||||
length = length * 256 + (prefix[index + 2] ?? 0);
|
||||
return length >= 128 && length + width + 2 <= available
|
||||
? length + width + 2
|
||||
: null;
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants, type BigIntStats } from "node:fs";
|
||||
import { lstat, open } from "node:fs/promises";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js";
|
||||
|
||||
/** Binding of commands to the selected executable's registered content/version. */
|
||||
export interface SignatureTargetBinding {
|
||||
readonly identity: string;
|
||||
}
|
||||
|
||||
const identity = (stat: BigIntStats): string =>
|
||||
[stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
||||
|
||||
const changedTarget = (target: BinaryTarget, reason: string) =>
|
||||
err(
|
||||
new AnalysisArtifactChangedError("inspect_signature", target.path, reason),
|
||||
);
|
||||
|
||||
const signatureReadFailure = (
|
||||
target: BinaryTarget,
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
if (code === "EACCES" || code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(
|
||||
"inspect_signature",
|
||||
target.path,
|
||||
code,
|
||||
{ cause },
|
||||
);
|
||||
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
|
||||
return new AnalysisArtifactChangedError(
|
||||
"inspect_signature",
|
||||
target.path,
|
||||
`Registered signature target is no longer readable (${code}): ${target.path}`,
|
||||
{ cause },
|
||||
);
|
||||
return new ProviderAdapterError(
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY.id,
|
||||
"inspect_signature",
|
||||
{
|
||||
cause,
|
||||
diagnostics: {
|
||||
path: target.path,
|
||||
phase: "target-version-binding",
|
||||
system_code: typeof code === "string" ? code : null,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Establish a bounded-memory digest/version baseline before external commands run. */
|
||||
export const bindSignatureTarget = async (
|
||||
target: BinaryTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SignatureTargetBinding, AnalysisError>> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const before = await lstat(target.path, { bigint: true });
|
||||
if (!before.isFile() || before.isSymbolicLink())
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target is no longer a regular file: ${target.path}`,
|
||||
);
|
||||
const file = await open(
|
||||
target.path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const opened = await file.stat({ bigint: true });
|
||||
if (identity(opened) !== identity(before))
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target changed before open: ${target.path}`,
|
||||
);
|
||||
const size = Number(opened.size);
|
||||
if (!Number.isSafeInteger(size))
|
||||
return err(
|
||||
new AnalysisResourceConstraintError(
|
||||
"inspect_signature",
|
||||
"file-size",
|
||||
`Signature target size is not exactly representable: ${target.path}`,
|
||||
null,
|
||||
),
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
let position = 0;
|
||||
while (position < size) {
|
||||
signal?.throwIfAborted();
|
||||
const { bytesRead } = await file.read(
|
||||
chunk,
|
||||
0,
|
||||
Math.min(chunk.length, size - position),
|
||||
position,
|
||||
);
|
||||
if (bytesRead === 0)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target was truncated while hashing: ${target.path}`,
|
||||
);
|
||||
hash.update(chunk.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
const observed = hash.digest("hex");
|
||||
if (observed !== target.sha256)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target digest changed: ${target.path}; expected ${target.sha256}, observed ${observed}`,
|
||||
);
|
||||
const after = await file.stat({ bigint: true });
|
||||
const current = await lstat(target.path, { bigint: true });
|
||||
if (
|
||||
identity(after) !== identity(opened) ||
|
||||
identity(current) !== identity(opened)
|
||||
)
|
||||
return changedTarget(
|
||||
target,
|
||||
`Signature target changed while establishing its version: ${target.path}`,
|
||||
);
|
||||
return ok({ identity: identity(opened) });
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
return err(signatureReadFailure(target, cause));
|
||||
}
|
||||
};
|
||||
|
||||
/** Reject drift between display, entitlement and verification captures. */
|
||||
export const verifySignatureTarget = async (
|
||||
target: BinaryTarget,
|
||||
binding: SignatureTargetBinding,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<void, AnalysisError>> => {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const current = await lstat(target.path, { bigint: true });
|
||||
return current.isFile() && identity(current) === binding.identity
|
||||
? ok(undefined)
|
||||
: changedTarget(
|
||||
target,
|
||||
`Signature target changed during inspection: ${target.path}`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
return err(signatureReadFailure(target, cause));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCodeSignature } from "./codesign.js";
|
||||
|
||||
const display = (lines: readonly string[]): string =>
|
||||
[
|
||||
"Identifier=com.example.fixture",
|
||||
"Format=app bundle with Mach-O thin (arm64)",
|
||||
...lines,
|
||||
"TeamIdentifier=TEAM12345",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("codesign CodeDirectory parsing", () => {
|
||||
it("decodes CodeDirectory fields and flags from the numeric value", () => {
|
||||
const parsed = parseCodeSignature(
|
||||
display([
|
||||
"CodeDirectory v=20500 size=443 flags=0x12a00(kill,restrict,library-validation,runtime) hashes=3209+7 location=embedded",
|
||||
"Platform identifier=26",
|
||||
"Hash type=sha256 size=32",
|
||||
"Executable Segment flags=0x1",
|
||||
"Runtime Version=26.4.0",
|
||||
"Sealed Resources version=2 rules=13 files=290",
|
||||
]),
|
||||
false,
|
||||
);
|
||||
expect(parsed.code_directory).toEqual({
|
||||
version: "20500",
|
||||
flags: {
|
||||
value: 0x12a00,
|
||||
names: ["kill", "restrict", "library-validation", "runtime"],
|
||||
},
|
||||
code_slots: 3209,
|
||||
special_slots: 7,
|
||||
location: "embedded",
|
||||
hash_type: "sha256",
|
||||
platform_identifier: 26,
|
||||
runtime_version: "26.4.0",
|
||||
executable_segment_flags: 1,
|
||||
});
|
||||
expect(parsed.hardened_runtime).toBe(true);
|
||||
expect(parsed.sealed_resources).toEqual({
|
||||
status: "sealed",
|
||||
version: 2,
|
||||
rules: 13,
|
||||
files: 290,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unsealed, unsigned and incomplete output without guessing", () => {
|
||||
const bare = parseCodeSignature(
|
||||
display([
|
||||
"CodeDirectory v=20400 size=336 location=embedded",
|
||||
"Sealed Resources=none",
|
||||
]),
|
||||
false,
|
||||
);
|
||||
expect(bare.code_directory).toMatchObject({
|
||||
flags: null,
|
||||
code_slots: null,
|
||||
platform_identifier: null,
|
||||
});
|
||||
expect(bare.sealed_resources.status).toBe("none");
|
||||
expect(parseCodeSignature(display([]), false)).toMatchObject({
|
||||
code_directory: null,
|
||||
sealed_resources: { status: "unknown" },
|
||||
});
|
||||
expect(
|
||||
parseCodeSignature("fixture: code object is not signed at all\n", true),
|
||||
).toMatchObject({
|
||||
signed: false,
|
||||
code_directory: null,
|
||||
sealed_resources: { status: "none" },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type CodeDirectory } from "../../domain/native/codeSigningPosture.js";
|
||||
import { codeDirectoryFlagNames } from "../CodeSigningPolicy.js";
|
||||
import {
|
||||
inspectSignatureSchema,
|
||||
type InspectSignature,
|
||||
@@ -23,11 +25,21 @@ export const parseCodeSignature = (
|
||||
const values = new Map<string, string>();
|
||||
const authorities: string[] = [];
|
||||
const cdhashes: string[] = [];
|
||||
let sealed: InspectSignature["sealed_resources"] = {
|
||||
status: "unknown",
|
||||
version: null,
|
||||
rules: null,
|
||||
files: null,
|
||||
};
|
||||
for (const line of fields.split("\n")) {
|
||||
if (line.startsWith("CodeDirectory ")) {
|
||||
values.set("CodeDirectory", line.slice("CodeDirectory ".length));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("Sealed Resources")) {
|
||||
sealed = parseSealedResources(line);
|
||||
continue;
|
||||
}
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) continue;
|
||||
const key = line.slice(0, separator).trim();
|
||||
@@ -48,6 +60,19 @@ export const parseCodeSignature = (
|
||||
entitlements: null,
|
||||
timestamp: values.get("Timestamp") ?? null,
|
||||
hardened_runtime: parseRuntime(values.get("CodeDirectory")),
|
||||
code_directory: unsigned ? null : parseCodeDirectory(values),
|
||||
sealed_resources: unsigned
|
||||
? { status: "none", version: null, rules: null, files: null }
|
||||
: sealed,
|
||||
verification: null,
|
||||
stapled_ticket: {
|
||||
status: "not-applicable",
|
||||
path: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
reason: null,
|
||||
},
|
||||
security_facets: [],
|
||||
limitations: unsigned
|
||||
? ["Artifact is not signed."]
|
||||
: [
|
||||
@@ -104,3 +129,62 @@ const parseRuntime = (value: string | undefined): boolean | null => {
|
||||
if (flags === undefined) return null;
|
||||
return flags.split(",").some((flag) => flag.trim() === "runtime");
|
||||
};
|
||||
|
||||
const integerField = (
|
||||
text: string | undefined,
|
||||
pattern: RegExp,
|
||||
radix = 10,
|
||||
): number | null => {
|
||||
const match = text === undefined ? undefined : pattern.exec(text)?.[1];
|
||||
if (match === undefined) return null;
|
||||
const value = Number.parseInt(match, radix);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `CodeDirectory v=20500 size=… flags=0x10000(runtime) hashes=3209+7
|
||||
* location=embedded`, plus the separate hash, platform, runtime-version and
|
||||
* executable-segment lines. Flag names are decoded from the numeric value.
|
||||
*/
|
||||
const parseCodeDirectory = (
|
||||
values: ReadonlyMap<string, string>,
|
||||
): CodeDirectory | null => {
|
||||
const line = values.get("CodeDirectory");
|
||||
if (line === undefined) return null;
|
||||
const flags = integerField(line, /(?:^|\s)flags=0x([0-9a-f]+)/iu, 16);
|
||||
return {
|
||||
version: /(?:^|\s)v=(\S+)/u.exec(line)?.[1] ?? null,
|
||||
flags:
|
||||
flags === null
|
||||
? null
|
||||
: { value: flags, names: codeDirectoryFlagNames(flags) },
|
||||
code_slots: integerField(line, /(?:^|\s)hashes=(\d+)\+/u),
|
||||
special_slots: integerField(line, /(?:^|\s)hashes=\d+\+(\d+)/u),
|
||||
location: /(?:^|\s)location=(\S+)/u.exec(line)?.[1] ?? null,
|
||||
hash_type: values.get("Hash type")?.split(/\s+/u)[0] ?? null,
|
||||
platform_identifier: integerField(
|
||||
values.get("Platform identifier"),
|
||||
/^(\d+)$/u,
|
||||
),
|
||||
runtime_version: values.get("Runtime Version") ?? null,
|
||||
executable_segment_flags: integerField(
|
||||
values.get("Executable Segment flags"),
|
||||
/^0x([0-9a-f]+)$/iu,
|
||||
16,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** `Sealed Resources version=2 rules=13 files=290` or `Sealed Resources=none`. */
|
||||
const parseSealedResources = (
|
||||
line: string,
|
||||
): InspectSignature["sealed_resources"] => {
|
||||
if (/^Sealed Resources\s*=\s*none$/u.test(line.trim()))
|
||||
return { status: "none", version: null, rules: null, files: null };
|
||||
return {
|
||||
status: "sealed",
|
||||
version: integerField(line, /\sversion=(\d+)/u),
|
||||
rules: integerField(line, /\srules=(\d+)/u),
|
||||
files: integerField(line, /\sfiles=(\d+)/u),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { signatureVerification } from "../../../../src/native/CodesignVerification.js";
|
||||
import { NativeFixtureRunner } from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
it.each(["permission denied", "I/O error", "EACCES"])(
|
||||
"does not classify echoed pathname %s as an I/O failure",
|
||||
async (text) => {
|
||||
const path = `/Applications/${text}.app`;
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const observed = signatureVerification(
|
||||
{
|
||||
...capture.value,
|
||||
exitCode: 1,
|
||||
stderr: `${path}: a sealed resource is missing or invalid\n`,
|
||||
},
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(observed.status).toBe("invalid");
|
||||
expect(observed.diagnostics).toEqual([
|
||||
`${path}: a sealed resource is missing or invalid`,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps operational, unsigned, definite invalid and unrecognized failures distinct", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const status = (reason: string, unsigned = false) =>
|
||||
signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr: `${path}: ${reason}\n` },
|
||||
path,
|
||||
unsigned,
|
||||
).status;
|
||||
expect(status("permission denied")).toBe("unknown");
|
||||
expect(status("I/O error")).toBe("unknown");
|
||||
expect(status("invalid signature")).toBe("invalid");
|
||||
expect(status("main executable failed strict validation")).toBe("invalid");
|
||||
expect(status("bundle format unrecognized, invalid, or unsuitable")).toBe(
|
||||
"invalid",
|
||||
);
|
||||
expect(
|
||||
status(
|
||||
"resource fork, Finder information, or similar detritus not allowed",
|
||||
),
|
||||
).toBe("invalid");
|
||||
expect(status("code object is not signed at all", true)).toBe("unsigned");
|
||||
expect(status("code object is not signed at all")).toBe("invalid");
|
||||
expect(status("unrecognized failure")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("excludes complete selected paths with embedded newlines before classifying reasons", async () => {
|
||||
const path =
|
||||
"/Applications/permission denied\ncode object is not signed at all\nFixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr: `${path}: invalid signature\n` },
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(result.status).toBe("invalid");
|
||||
expect(result.diagnostics.join("\n")).toContain(path);
|
||||
});
|
||||
|
||||
it("preserves definitive invalidity when other components have operational failures", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const observed = signatureVerification(
|
||||
{
|
||||
...capture.value,
|
||||
exitCode: 1,
|
||||
stderr: `${path}/Contents/Helpers/one: permission denied\n${path}/Contents/Helpers/two: invalid signature\n`,
|
||||
},
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(observed.status).toBe("invalid");
|
||||
expect(observed.diagnostics).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves unmatched prepared records and ignores unprefixed nested-name fragments as reasons", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const stderr = `--prepared:${path}/Contents/Helpers/odd\ninvalid signature\n${path}: permission denied\n`;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr },
|
||||
path,
|
||||
false,
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.prepared_nested_code).toEqual([`${path}/Contents/Helpers/odd`]);
|
||||
expect(result.raw_stderr).toBe(stderr);
|
||||
expect(result.diagnostics).toContain("invalid signature");
|
||||
});
|
||||
|
||||
it("keeps failed verification unknown when nested progress can contain a selected-path diagnostic", async () => {
|
||||
const path = "/Applications/Fixture.app";
|
||||
const capture = await new NativeFixtureRunner().run("codesign", [
|
||||
"--verify",
|
||||
path,
|
||||
]);
|
||||
if (!capture.ok) throw capture.error;
|
||||
const stderr = `--prepared:${path}/Contents/Helpers/odd\n${path}: invalid signature\n${path}: permission denied\n`;
|
||||
const result = signatureVerification(
|
||||
{ ...capture.value, exitCode: 1, stderr },
|
||||
path,
|
||||
false,
|
||||
"ambiguous",
|
||||
);
|
||||
expect(result.status).toBe("unknown");
|
||||
expect(result.raw_stderr).toBe(stderr);
|
||||
expect(
|
||||
signatureVerification(
|
||||
{ ...capture.value, exitCode: 0, stderr },
|
||||
path,
|
||||
false,
|
||||
"ambiguous",
|
||||
).status,
|
||||
).toBe("valid");
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -18,9 +19,14 @@ import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
|
||||
import {
|
||||
NativeFixtureRunner as FixtureRunner,
|
||||
nativeFixture as fixture,
|
||||
nativeMachoTarget as machoTarget,
|
||||
nativeMachoTarget as unboundMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const machoTarget = (path: string, app?: string) => ({
|
||||
...unboundMachoTarget(path, app),
|
||||
sha256: createHash("sha256").update("fixture").digest("hex"),
|
||||
});
|
||||
|
||||
let directory: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (directory !== undefined)
|
||||
@@ -318,10 +324,13 @@ class PrototypeEntitlementsRunner extends FixtureRunner {
|
||||
|
||||
describe("native signature entitlements", () => {
|
||||
it("reports entitlement entries keyed __proto__ that the result omits", async () => {
|
||||
directory = await createTestTempDirectory("rea-signature-entitlements-");
|
||||
const executable = join(directory, "fixture");
|
||||
await writeFile(executable, "fixture");
|
||||
const client = new NativeMacOSProvider(
|
||||
new PrototypeEntitlementsRunner(),
|
||||
"darwin",
|
||||
).createClient(machoTarget("/private/fixture"));
|
||||
).createClient(machoTarget(executable));
|
||||
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
|
||||
import { stapledTicket } from "../../../../src/native/NativeSignaturePosture.js";
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ok } from "../../../../src/domain/result.js";
|
||||
import { inspectSignatureSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
import { notarizationTicketFixture } from "../../../fixtures/notarizationTicket.js";
|
||||
import {
|
||||
NativeFixtureRunner as FixtureRunner,
|
||||
nativeMachoTarget as unboundMachoTarget,
|
||||
} from "../../../fixtures/nativeCommands.js";
|
||||
|
||||
const machoTarget = (path: string, app?: string) => ({
|
||||
...unboundMachoTarget(path, app),
|
||||
sha256: createHash("sha256").update("fixture").digest("hex"),
|
||||
});
|
||||
|
||||
const TAMPERED_STDERR =
|
||||
"--prepared:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/XPCServices/B.xpc\n--validated:/Applications/Fixture.app/Contents/PlugIns/A.appex\n/Applications/Fixture.app: a sealed resource is missing or invalid\nfile modified: /Applications/Fixture.app/Contents/Resources/en.lproj/Main.nib\n";
|
||||
|
||||
/** Fail only strict verification, as a tampered bundle does. */
|
||||
class TamperedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = TAMPERED_STDERR.replaceAll(
|
||||
"/Applications/Fixture.app",
|
||||
arguments_.at(-1) ?? "",
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureApp = async (ticket: string | Uint8Array | undefined) => {
|
||||
const directory = await createTestTempDirectory("rea-signature-posture-");
|
||||
const app = join(directory, "Fixture.app");
|
||||
const executable = join(app, "Contents/MacOS/Fixture");
|
||||
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
|
||||
await writeFile(executable, "fixture");
|
||||
if (ticket !== undefined)
|
||||
await writeFile(join(app, "Contents/CodeResources"), ticket);
|
||||
return { app, executable };
|
||||
};
|
||||
|
||||
describe("native signature posture", () => {
|
||||
it("verifies the opened bundle and reports a stapled ticket", async () => {
|
||||
const ticket = notarizationTicketFixture();
|
||||
const { app, executable } = await fixtureApp(ticket);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
|
||||
expect(signature.ok).toBe(true);
|
||||
if (!signature.ok) return;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.code_directory).toMatchObject({
|
||||
version: "20500",
|
||||
flags: { value: 0x10000, names: ["runtime"] },
|
||||
code_slots: 10,
|
||||
special_slots: 7,
|
||||
});
|
||||
expect(result.verification).toMatchObject({
|
||||
path: app,
|
||||
status: "valid",
|
||||
exit_code: 0,
|
||||
});
|
||||
expect(result.stapled_ticket).toEqual({
|
||||
status: "present",
|
||||
path: "Contents/CodeResources",
|
||||
sha256: createHash("sha256").update(ticket).digest("hex"),
|
||||
size: ticket.length,
|
||||
reason: null,
|
||||
});
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
result.security_facets.map(({ facet, state }) => [facet, state]),
|
||||
),
|
||||
).toEqual({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
"executable-memory": "restricted",
|
||||
"app-sandbox": "sandboxed",
|
||||
});
|
||||
expect(result.provenance.map(({ command }) => command[1])).toContain(
|
||||
"--verify",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a failed strict verification as an observation", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
await mkdir(join(app, "Contents/PlugIns/A.appex"), { recursive: true });
|
||||
await mkdir(join(app, "Contents/XPCServices/B.xpc"), { recursive: true });
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new TamperedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
|
||||
expect(signature.ok).toBe(true);
|
||||
if (!signature.ok) return;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification).toMatchObject({
|
||||
path: app,
|
||||
status: "invalid",
|
||||
exit_code: 1,
|
||||
diagnostics: [
|
||||
`${app}: a sealed resource is missing or invalid`,
|
||||
`file modified: ${app}/Contents/Resources/en.lproj/Main.nib`,
|
||||
],
|
||||
validated_nested_code: [
|
||||
`${app}/Contents/PlugIns/A.appex`,
|
||||
`${app}/Contents/XPCServices/B.xpc`,
|
||||
],
|
||||
});
|
||||
expect(result.stapled_ticket.status).toBe("absent");
|
||||
});
|
||||
|
||||
it("does not look for a stapled ticket beside a bare Mach-O", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-bare-");
|
||||
const executable = join(directory, "tool");
|
||||
await writeFile(executable, "fixture");
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: executable },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"keeps inspecting when the stapled ticket cannot be read",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp("ticket");
|
||||
const ticket = join(app, "Contents/CodeResources");
|
||||
await chmod(ticket, 0o000);
|
||||
try {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
stapled_ticket: {
|
||||
status: "unreadable",
|
||||
path: "Contents/CodeResources",
|
||||
sha256: null,
|
||||
reason: "EACCES",
|
||||
},
|
||||
verification: { status: "valid" },
|
||||
});
|
||||
} finally {
|
||||
await chmod(ticket, 0o644);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports unsigned nested code inside a signed bundle as invalid", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UnsignedNestedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
signed: true,
|
||||
verification: { status: "invalid", exit_code: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native signature posture boundaries", () => {
|
||||
it("verifies the inner bundle for iOS-on-Mac wrappers", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-wrapper-");
|
||||
const outer = join(directory, "WrapperApp.app");
|
||||
const inner = join(outer, "Wrapper", "Inner.app");
|
||||
const executable = join(inner, "Contents/MacOS/Tool");
|
||||
await mkdir(join(inner, "Contents/MacOS"), { recursive: true });
|
||||
await writeFile(executable, "fixture");
|
||||
const target = {
|
||||
...machoTarget(executable),
|
||||
sourcePath: outer,
|
||||
bundleInfoPlist: join(inner, "Contents/Info.plist"),
|
||||
};
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(target)
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: inner },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a regular file named .app as a bare Mach-O", async () => {
|
||||
const directory = await createTestTempDirectory("rea-signature-file-");
|
||||
const executable = join(directory, "Tool.app");
|
||||
await writeFile(executable, "fixture");
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient({ ...machoTarget(executable), sourcePath: executable })
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok && signature.value.result).toMatchObject({
|
||||
verification: { path: executable },
|
||||
stapled_ticket: { status: "not-applicable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies platform policy only after the signature satisfies anchor apple", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const facets = async (anchorExit: number) => {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new PlatformRunner(anchorExit),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.provenance.map(({ command }) => command.at(-2))).toContain(
|
||||
"-R=anchor apple",
|
||||
);
|
||||
if (anchorExit === 0)
|
||||
expect(
|
||||
result.limitations.some((line) =>
|
||||
line.startsWith("Apple-origin check failed"),
|
||||
),
|
||||
).toBe(false);
|
||||
else
|
||||
expect(result.limitations).toContain(
|
||||
"Apple-origin check failed: code failed to satisfy specified code requirement(s)",
|
||||
);
|
||||
return Object.fromEntries(
|
||||
result.security_facets.map(({ facet, state }) => [facet, state]),
|
||||
);
|
||||
};
|
||||
expect(await facets(0)).toMatchObject({
|
||||
"library-validation": "enforced",
|
||||
"dyld-environment-variables": "ignored",
|
||||
"debugger-attach": "blocked",
|
||||
});
|
||||
expect(await facets(3)).toMatchObject({
|
||||
"library-validation": "unknown",
|
||||
"dyld-environment-variables": "unknown",
|
||||
"debugger-attach": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags reported nested code that does not exist as a split path", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new NewlinePathRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// The line projection splits the name; the fragment is flagged.
|
||||
expect(result.verification?.validated_nested_code).toEqual([
|
||||
`${app}/Contents/Helpers/odd`,
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining(
|
||||
`validated nested code at ${JSON.stringify(`${app}/Contents/Helpers/odd`)}, which does not exist`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports cancellation while hashing the stapled ticket", async () => {
|
||||
const { app, executable } = await fixtureApp("ticket");
|
||||
const controller = new AbortController();
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new AbortAfterVerifyRunner(controller),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {}, { signal: controller.signal });
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) return;
|
||||
expect(signature.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native signature posture slice-aware cases", () => {
|
||||
it("keeps main-executable facets when only nested code fails", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new NestedOnlyFailureRunner(app),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("invalid");
|
||||
expect(
|
||||
result.security_facets.find(({ facet }) => facet === "library-validation")
|
||||
?.state,
|
||||
).toBe("enforced");
|
||||
});
|
||||
|
||||
it("probes every slice of a universal binary before declaring it signed", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalMixedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// Aggregate display calls report the signed native slice; the unsigned
|
||||
// x86_64 slice is found only per-architecture.
|
||||
expect(result.verification?.status).toBe("valid");
|
||||
expect(
|
||||
result.security_facets.find(
|
||||
({ facet }) => facet === "library-validation",
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "unknown",
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
});
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Unsigned Mach-O slices: x86_64."),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a trailing carriage return in reported nested paths", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new CarriageReturnPathRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// The CR is a valid pathname byte, not a line delimiter: it is kept and
|
||||
// the nonexistent path is flagged rather than silently normalized.
|
||||
expect(result.verification?.validated_nested_code).toEqual([
|
||||
`${app}/Contents/Helpers/cr\r`,
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("which does not exist"),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks facets unknown when signed slices differ in posture", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalPostureDiffRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
// Every slice verifies, but their CodeDirectory flags disagree.
|
||||
expect(result.verification?.status).toBe("valid");
|
||||
expect(
|
||||
result.security_facets.find(
|
||||
({ facet }) => facet === "library-validation",
|
||||
),
|
||||
).toMatchObject({
|
||||
state: "unknown",
|
||||
evidence: ["architecture slices differ in signing state"],
|
||||
});
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("differ in CodeDirectory flags or entitlements"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports verification I/O failures as unknown, not invalid", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new PermissionDeniedRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("not a proven broken signature"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature architecture coverage", () => {
|
||||
it("does not let uncertain display probes hide definitive verification failure", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new DefiniteInvalidWithUncertainSlicesRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.signed).toBe(true);
|
||||
expect(result.verification?.status).toBe("invalid");
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("could not be classified"),
|
||||
);
|
||||
});
|
||||
it("does not turn inconclusive slice probes into an unsigned claim", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new InconclusiveSlicesRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.signed).toBe(true);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(
|
||||
result.security_facets.every(({ state }) => state === "unknown"),
|
||||
).toBe(true);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("could not be classified"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not report arbitrary ticket-location bytes as a stapled ticket", async () => {
|
||||
const bytes = "stapled ticket bytes";
|
||||
const { app, executable } = await fixtureApp(bytes);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.stapled_ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
size: bytes.length,
|
||||
reason: "Not a recognized s8ch notarization ticket container",
|
||||
});
|
||||
});
|
||||
it("probes a valid x86_64h subtype rather than dropping it", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalMixedRunner("x86_64h"),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.limitations).toContain("Unsigned Mach-O slices: x86_64h.");
|
||||
expect(
|
||||
result.security_facets.every(({ state }) => state === "unknown"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains per-slice entitlement captures used to compare posture", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new UniversalPostureDiffRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
const commands = result.provenance.filter(
|
||||
({ command }) =>
|
||||
command.includes("-a") && command.includes("--entitlements"),
|
||||
);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(result.entitlements).toEqual({
|
||||
"com.apple.security.app-sandbox": true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("signature target version binding", () => {
|
||||
it("rejects a replacement before invoking signature tools", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const runner = new (class extends FixtureRunner {
|
||||
calls = 0;
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
this.calls += 1;
|
||||
return super.run(tool, args);
|
||||
}
|
||||
})();
|
||||
const client = new NativeMacOSProvider(runner, "darwin").createClient(
|
||||
machoTarget(executable, app),
|
||||
);
|
||||
await writeFile(executable, "replacement bytes");
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected target change");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: executable },
|
||||
});
|
||||
expect(runner.calls).toBe(0);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"rejects an unreadable selected executable",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const client = new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
).createClient(machoTarget(executable, app));
|
||||
await chmod(executable, 0);
|
||||
try {
|
||||
const signature = await client.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected access denial");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
details: { path: executable },
|
||||
});
|
||||
} finally {
|
||||
await chmod(executable, 0o600);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a regular ticket replaced by a directory as changed", async () => {
|
||||
const { app } = await fixtureApp(notarizationTicketFixture());
|
||||
const ticket = await stapledTicket(
|
||||
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
|
||||
undefined,
|
||||
async (path, phase) => {
|
||||
const metadata = await lstat(path);
|
||||
if (phase === "before") {
|
||||
await rm(path);
|
||||
await mkdir(path);
|
||||
}
|
||||
return metadata;
|
||||
},
|
||||
);
|
||||
expect(ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
path: "Contents/CodeResources",
|
||||
reason: "changed",
|
||||
sha256: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["EACCES", "EPERM", "EIO"])(
|
||||
"preserves final ticket-path failure %s instead of inventing mutation",
|
||||
async (code) => {
|
||||
const { app } = await fixtureApp(notarizationTicketFixture());
|
||||
const ticket = await stapledTicket(
|
||||
{ sourcePath: app, bundleInfoPlist: join(app, "Contents/Info.plist") },
|
||||
undefined,
|
||||
async (path, phase) => {
|
||||
if (phase === "after")
|
||||
throw Object.assign(new Error("host lookup failed"), { code });
|
||||
return lstat(path);
|
||||
},
|
||||
);
|
||||
expect(ticket).toMatchObject({
|
||||
status: "unreadable",
|
||||
reason: code,
|
||||
sha256: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
it("rejects observations when the executable changes between captures", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new ReplacingExecutableRunner(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(signature.ok).toBe(false);
|
||||
if (signature.ok) throw new Error("Expected target change");
|
||||
expect(projectAnalysisError(signature.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: executable },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"reports denied nested-path completeness checks",
|
||||
async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const helpers = join(app, "Contents/Helpers");
|
||||
await mkdir(helpers);
|
||||
await chmod(helpers, 0);
|
||||
try {
|
||||
const signature = await new NativeMacOSProvider(
|
||||
new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
if (!result.ok || args[0] !== "--verify") return result;
|
||||
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
})(),
|
||||
"darwin",
|
||||
)
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!signature.ok) throw signature.error;
|
||||
const result = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.verification?.raw_stderr).toContain(
|
||||
`${app}: invalid signature`,
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("Could not confirm completeness"),
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("permission denied (EACCES)"),
|
||||
);
|
||||
} finally {
|
||||
await chmod(helpers, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves failed display diagnostics through the caller-facing error contract", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const diagnostic = `${executable}: permission denied while reading signature\n`;
|
||||
const runner = new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
return result.ok && args.includes("--verbose=4")
|
||||
? ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr: diagnostic,
|
||||
stderrBytes: Buffer.byteLength(diagnostic),
|
||||
})
|
||||
: result;
|
||||
}
|
||||
})();
|
||||
const result = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected display failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
diagnostics: {
|
||||
path: executable,
|
||||
exit_code: 1,
|
||||
raw_stderr: diagnostic,
|
||||
arguments: ["-d", "--verbose=4", executable],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses filesystem-confirmed newline ambiguity to qualify verification without losing raw evidence", async () => {
|
||||
const { app, executable } = await fixtureApp(undefined);
|
||||
const helpers = join(app, "Contents/Helpers");
|
||||
await mkdir(helpers);
|
||||
// A slash cannot be part of a POSIX basename. An absolute-looking continuation
|
||||
// is represented by nested directories following the newline in the prefix.
|
||||
const injected = join(helpers, `odd\n${app}: invalid signature`);
|
||||
await mkdir(injected, { recursive: true });
|
||||
const runner = new (class extends FixtureRunner {
|
||||
override async run(tool: string, args: readonly string[]) {
|
||||
const result = await super.run(tool, args);
|
||||
if (!result.ok || args[0] !== "--verify") return result;
|
||||
const stderr = `--prepared:${helpers}/odd\n${app}: invalid signature\n${app}: permission denied\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: 1,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
})();
|
||||
const observed = await new NativeMacOSProvider(runner, "darwin")
|
||||
.createClient(machoTarget(executable, app))
|
||||
.execute("inspect_signature", {});
|
||||
if (!observed.ok) throw observed.error;
|
||||
const result = inspectSignatureSchema.parse(observed.value.result);
|
||||
expect(result.verification?.status).toBe("unknown");
|
||||
expect(result.verification?.raw_stderr).toContain(
|
||||
`${app}: invalid signature`,
|
||||
);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("structured nested-code records are ambiguous"),
|
||||
);
|
||||
});
|
||||
|
||||
/** Mutate only the fixture after its initial display has been observed. */
|
||||
class ReplacingExecutableRunner extends FixtureRunner {
|
||||
#changed = false;
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (
|
||||
!this.#changed &&
|
||||
tool === "codesign" &&
|
||||
arguments_.includes("--verbose=4")
|
||||
) {
|
||||
const path = arguments_.at(-1);
|
||||
if (path !== undefined)
|
||||
await writeFile(path, "replacement executable bytes");
|
||||
this.#changed = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** A signed bundle whose nested helper is unsigned. */
|
||||
class UnsignedNestedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr =
|
||||
"/Applications/Fixture.app/Contents/Helpers/tool: code object is not signed at all\nIn subcomponent: /Applications/Fixture.app/Contents/Helpers/tool\n";
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A platform binary whose `anchor apple` requirement check exits as given. */
|
||||
class PlatformRunner extends FixtureRunner {
|
||||
constructor(private readonly anchorExit: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (arguments_.includes("-R=anchor apple")) {
|
||||
const target = arguments_.at(-1) ?? "";
|
||||
const stderr =
|
||||
this.anchorExit === 0
|
||||
? ""
|
||||
: `${target}: code failed to satisfy specified code requirement(s)\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
exitCode: this.anchorExit,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
if (!arguments_.includes("--verbose=4")) return result;
|
||||
const stderr = `${result.value.stderr}Platform identifier=26\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A nested helper whose file name contains a newline. */
|
||||
class NewlinePathRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const bundle = arguments_.at(-1) ?? "";
|
||||
const stderr = `--validated:${bundle}/Contents/Helpers/odd\nname.app\n${bundle}: valid on disk\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Bundle verification fails on nested code while the main executable passes. */
|
||||
class NestedOnlyFailureRunner extends FixtureRunner {
|
||||
constructor(private readonly bundle: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
if (arguments_.at(-1) !== this.bundle) return result;
|
||||
const stderr = `${this.bundle}: a sealed resource is missing or invalid\nfile modified: ${this.bundle}/Contents/XPCServices/Svc.xpc\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A universal binary whose aggregate display calls report the signed native
|
||||
* slice while the x86_64 slice is unsigned.
|
||||
*/
|
||||
class UniversalMixedRunner extends FixtureRunner {
|
||||
constructor(private readonly foreignArchitecture = "x86_64") {
|
||||
super();
|
||||
}
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
const architectureIndex = arguments_.indexOf("-a");
|
||||
if (architectureIndex >= 0) {
|
||||
if (arguments_[architectureIndex + 1] !== this.foreignArchitecture)
|
||||
return result;
|
||||
const stderr = `${arguments_.at(-1)}: code object is not signed at all\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
if (!arguments_.includes("--verbose=4")) return result;
|
||||
const stderr = result.value.stderr.replace(
|
||||
/^Format=.*$/mu,
|
||||
`Format=Mach-O universal (${this.foreignArchitecture} arm64)`,
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Aggregate display succeeds, but per-architecture probes and verification fail operationally. */
|
||||
class InconclusiveSlicesRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (!arguments_.includes("-a") && arguments_[0] !== "--verify")
|
||||
return result;
|
||||
const stderr = "I/O error reading signing data\n";
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Slice display uncertainty and an independently established invalid signature. */
|
||||
class DefiniteInvalidWithUncertainSlicesRunner extends InconclusiveSlicesRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = `${arguments_.at(-1)}: invalid signature\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stderr,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A nested helper whose file name ends in a carriage return. */
|
||||
class CarriageReturnPathRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const bundle = arguments_.at(-1) ?? "";
|
||||
const stderr = `--validated:${bundle}/Contents/Helpers/cr\r\n${bundle}: valid on disk\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A universal binary whose slices all verify but carry different
|
||||
* CodeDirectory flags (hardened arm64, plain x86_64).
|
||||
*/
|
||||
class UniversalPostureDiffRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || tool !== "codesign") return result;
|
||||
if (arguments_.includes("--entitlements")) return result;
|
||||
const architectureIndex = arguments_.indexOf("-a");
|
||||
const architecture =
|
||||
architectureIndex < 0 ? null : arguments_[architectureIndex + 1];
|
||||
let stderr = result.value.stderr;
|
||||
if (architecture === null)
|
||||
stderr = stderr.replace(
|
||||
/^Format=.*$/mu,
|
||||
"Format=Mach-O universal (x86_64 arm64)",
|
||||
);
|
||||
else if (architecture === "x86_64")
|
||||
stderr = stderr.replace(
|
||||
/flags=0x[0-9a-f]+\([^)]*\)/u,
|
||||
"flags=0x2(adhoc)",
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Verification fails because nested code cannot be read. */
|
||||
class PermissionDeniedRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (!result.ok || arguments_[0] !== "--verify") return result;
|
||||
const stderr = `${arguments_.at(-1)}: permission denied while reading nested code\n`;
|
||||
return ok({
|
||||
...result.value,
|
||||
stdout: "",
|
||||
stderr,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: Buffer.byteLength(stderr),
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the request once local verification has finished. */
|
||||
class AbortAfterVerifyRunner extends FixtureRunner {
|
||||
constructor(private readonly controller: AbortController) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
const result = await super.run(tool, arguments_);
|
||||
if (arguments_[0] === "--verify") this.controller.abort();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Vendored
+9
-2
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
|
||||
import { ok } from "../../src/domain/result.js";
|
||||
import type {
|
||||
@@ -95,10 +96,16 @@ const capture = (
|
||||
/** Build the Mach-O target identity used by native boundary fixtures. */
|
||||
export const nativeMachoTarget = (
|
||||
path: string,
|
||||
sourcePath?: string,
|
||||
/** The app bundle directory the target was opened from. */
|
||||
bundle?: string,
|
||||
): BinaryTarget => ({
|
||||
path,
|
||||
...(sourcePath === undefined ? {} : { sourcePath }),
|
||||
...(bundle === undefined
|
||||
? {}
|
||||
: {
|
||||
sourcePath: bundle,
|
||||
bundleInfoPlist: join(bundle, "Contents/Info.plist"),
|
||||
}),
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
/** Synthetic local ticket framing; it does not contain an authenticated certificate/signature. */
|
||||
export const notarizationTicketFixture = (): Uint8Array => {
|
||||
const certificates = Buffer.from([0x30, 2, 0x30, 0]);
|
||||
const content = Buffer.alloc(45);
|
||||
content.write("g8tk", 0, "ascii");
|
||||
content.writeUInt16LE(2, 4);
|
||||
content.writeUInt16LE(20, 6);
|
||||
content.writeUInt32LE(1, 8);
|
||||
content[24] = 2;
|
||||
content.fill(0xab, 25);
|
||||
const signature = Buffer.alloc(72);
|
||||
signature.set([0x30, 6, 2, 1, 1, 2, 1, 1]);
|
||||
const header = Buffer.alloc(16);
|
||||
header.write("s8ch", 0, "ascii");
|
||||
header.writeUInt32LE(1, 4);
|
||||
header.writeUInt32LE(certificates.length, 8);
|
||||
header.writeUInt32LE(content.length, 12);
|
||||
return Buffer.concat([header, certificates, content, signature]);
|
||||
};
|
||||
Reference in New Issue
Block a user