Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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