From 335855c35670665345dec23bf3aeec2e09150084 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:46:34 +0000 Subject: [PATCH] Add macOS base publication tooling publish-macos-base turns a stopped, audited macvm bundle into a verified OCI machine image in the internal registry and writes an immutable receipt. audit-base.py checks a guest for a clean, agent-provisioned base before publication. Includes the example build source and the contract CI job. --- .github/workflows/test.yml | 16 ++ cmd/publish-macos-base/main.go | 219 ++++++++++++++++++++++ cmd/publish-macos-base/main_test.go | 128 +++++++++++++ cmd/publish-macos-base/snapshot_darwin.go | 60 ++++++ cmd/publish-macos-base/snapshot_other.go | 16 ++ docs/macos-base-images.md | 194 +++++++++++++++++++ scripts/macos/.gitignore | 1 + scripts/macos/audit-base.py | 178 ++++++++++++++++++ scripts/macos/example/build.sh | 8 + scripts/macos/test_audit.py | 68 +++++++ 10 files changed, 888 insertions(+) create mode 100644 cmd/publish-macos-base/main.go create mode 100644 cmd/publish-macos-base/main_test.go create mode 100644 cmd/publish-macos-base/snapshot_darwin.go create mode 100644 cmd/publish-macos-base/snapshot_other.go create mode 100644 docs/macos-base-images.md create mode 100644 scripts/macos/.gitignore create mode 100755 scripts/macos/audit-base.py create mode 100644 scripts/macos/example/build.sh create mode 100644 scripts/macos/test_audit.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c99517e68..1f847292f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,22 @@ permissions: contents: read jobs: + test-macos-pipeline-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ env.TEST_SOURCE_REPO }} + ref: ${{ env.TEST_SOURCE_REF }} + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Check macOS pipeline contracts + run: | + python3 -m unittest discover -s scripts/macos -p 'test_*.py' -v + sh -n scripts/macos/example/build.sh + test: runs-on: [self-hosted, linux, x64, kvm] steps: diff --git a/cmd/publish-macos-base/main.go b/cmd/publish-macos-base/main.go new file mode 100644 index 000000000..4d9767cc6 --- /dev/null +++ b/cmd/publish-macos-base/main.go @@ -0,0 +1,219 @@ +// publish-macos-base is an offline administrator tool, not an HTTP filesystem API. +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/kernel/hypeman/lib/images" +) + +type audit struct { + Version int `json:"version"` + Status string `json:"status"` + OSVersion string `json:"os_version"` + OSBuild string `json:"os_build"` + Architecture string `json:"architecture"` + AgentSHA256 string `json:"agent_sha256"` + PlistSHA256 string `json:"plist_sha256"` + ToolchainSHA256 string `json:"toolchain_sha256"` + ToolchainVersion string `json:"toolchain_version"` + HomeBaselineSHA256 string `json:"home_baseline_sha256"` + AuditScriptSHA256 string `json:"audit_script_sha256"` +} + +type receipt struct { + Version int `json:"version"` + Reference string `json:"reference"` + Digest string `json:"digest"` + Audit audit `json:"audit"` + AuditSHA256 string `json:"audit_sha256"` + PayloadSHA256 map[string]string `json:"payload_sha256"` + PublishedAt time.Time `json:"published_at"` +} + +func main() { + source := flag.String("source", "", "exclusive stopped macvm bundle") + auditPath := flag.String("audit", "", "guest audit JSON from final GuestService exec") + registry := flag.String("registry", "", "configured internal registry URL") + key := flag.String("registry-secret-file", "", "private file containing internal registry signing secret") + id := flag.String("id", "", "unique lowercase base publication ID (base-...) ") + out := flag.String("output", "", "new receipt file (must not exist)") + stopped := flag.Bool("stopped", false, "attest graceful shutdown and VMM exit; no restart during export") + clean := flag.Bool("clean-install-reviewed", false, "attest fresh installation, no real credentials ever, final audit via GuestService") + timeout := flag.Duration("timeout", 2*time.Hour, "snapshot and publication deadline") + flag.Parse() + if *source == "" || *auditPath == "" || *registry == "" || *key == "" || *out == "" || !*stopped || !*clean || !strings.HasPrefix(*id, "base-") || len(*id) > 128 || strings.Trim(*id, "abcdefghijklmnopqrstuvwxyz0123456789-") != "" || *timeout <= 0 || *timeout > 24*time.Hour { + fmt.Fprintln(os.Stderr, "required: --source --audit --registry --registry-secret-file --id base-... --output --stopped --clean-install-reviewed; timeout must be in (0,24h]") + os.Exit(2) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + ctx, deadline := context.WithTimeout(ctx, *timeout) + defer deadline() + if err := run(ctx, *source, *auditPath, *registry, *key, *id, *out); err != nil { + // Registry/OS errors can contain credentials, paths or server response bodies. + fmt.Fprintln(os.Stderr, "base publication failed; no verified receipt (check private operator diagnostics)") + os.Exit(1) + } +} + +func readAudit(path string) (audit, string, error) { + var a audit + f, err := os.Open(path) + if err != nil { + return a, "", err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, 65537)) + if err != nil || len(data) > 65536 { + return a, "", fmt.Errorf("invalid audit size") + } + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err = dec.Decode(&a); err != nil { + return a, "", err + } + if dec.Decode(new(any)) != io.EOF || a.Version != 1 || a.Status != "pass" || a.Architecture != "arm64" { + return a, "", fmt.Errorf("audit rejected") + } + for _, hash := range []string{a.AgentSHA256, a.PlistSHA256, a.ToolchainSHA256, a.HomeBaselineSHA256, a.AuditScriptSHA256} { + if !hashValid(hash) { + return a, "", fmt.Errorf("audit hash rejected") + } + } + if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)*$`).MatchString(a.OSVersion) || + !regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(a.OSBuild) || + !regexp.MustCompile(`^Xcode [0-9.]+; Build version [a-zA-Z0-9]+$`).MatchString(a.ToolchainVersion) { + return a, "", fmt.Errorf("audit provenance rejected") + } + sum := sha256.Sum256(data) + return a, hex.EncodeToString(sum[:]), nil +} + +type deadlineReader struct { + ctx context.Context + reader io.Reader +} + +func (r deadlineReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} + +func hashValid(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" } + +func validateSource(root string) error { + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("private real bundle directory required") + } + entries, err := os.ReadDir(root) + if err != nil || len(entries) != 3 { + return fmt.Errorf("only disk.img, aux.img and config.json allowed") + } + for _, name := range []string{"disk.img", "aux.img", "config.json"} { + info, err := os.Lstat(filepath.Join(root, name)) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { + return fmt.Errorf("private regular payload required") + } + } + platform, err := images.ValidateMacOSBundle(root) + if err != nil { + return err + } + if !platform.GuestAgent { + return fmt.Errorf("system guest agent required") + } + return nil +} + +func run(ctx context.Context, source, auditPath, registry, key, id, out string) error { + a, auditHash, err := readAudit(auditPath) + if err != nil { + return err + } + if err = validateSource(source); err != nil { + return err + } + // Reserve before publishing: never overwrite a prior release receipt. + output, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + complete := false + defer func() { + output.Close() + if !complete { + os.Remove(out) + } + }() + info, err := os.Lstat(key) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() < 32 || info.Size() > 4096 { + return fmt.Errorf("private registry signing key required") + } + secret, err := os.ReadFile(key) + if err != nil { + return err + } + secretText := strings.TrimSpace(string(secret)) + if len(secretText) < 32 { + return fmt.Errorf("registry signing key is too short") + } + workspace, err := os.MkdirTemp("", "hypeman-base-") + if err != nil { + return err + } + defer os.RemoveAll(workspace) + bundle, err := snapshot(ctx, source, workspace) + if err != nil { + return err + } + if err = validateSource(bundle); err != nil { + return err + } + hashes := map[string]string{} + for _, name := range []string{"disk.img", "aux.img", "config.json"} { + f, err := os.Open(filepath.Join(bundle, name)) + if err != nil { + return err + } + h := sha256.New() + _, err = io.Copy(h, deadlineReader{ctx, f}) + f.Close() + if err != nil { + return err + } + hashes[name] = hex.EncodeToString(h.Sum(nil)) + } + reference, digest, err := publish(ctx, registry, secretText, id, bundle) + if err != nil { + return err + } + r := receipt{1, reference, digest, a, auditHash, hashes, time.Now().UTC()} + if err = json.NewEncoder(output).Encode(r); err != nil { + return err + } + if err = output.Sync(); err != nil { + return err + } + if err = output.Close(); err != nil { + return err + } + complete = true + return nil +} diff --git a/cmd/publish-macos-base/main_test.go b/cmd/publish-macos-base/main_test.go new file mode 100644 index 000000000..2af2c597e --- /dev/null +++ b/cmd/publish-macos-base/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kernel/hypeman/lib/images" +) + +func goodAudit() audit { + h := strings.Repeat("a", 64) + return audit{Version: 1, Status: "pass", OSVersion: "15.5", OSBuild: "24F74", Architecture: "arm64", AgentSHA256: h, PlistSHA256: h, ToolchainSHA256: h, ToolchainVersion: "Xcode 16.4; Build version 16F6", HomeBaselineSHA256: h, AuditScriptSHA256: h} +} +func TestReadAudit(t *testing.T) { + a := goodAudit() + data, _ := json.Marshal(a) + tests := map[string][]byte{"valid": data, "trailing": append(append([]byte{}, data...), []byte(" {}")...), "unknown": []byte(strings.Replace(string(data), `"status":"pass"`, `"status":"pass","credential":"secret"`, 1)), "oversized": []byte(strings.Repeat(" ", 65537))} + bad := a + bad.Status = "fail" + tests["failed"], _ = json.Marshal(bad) + bad = a + bad.OSVersion = "secret-or-command-output" + tests["unsafe-provenance"], _ = json.Marshal(bad) + bad = a + bad.AgentSHA256 = "" + tests["missing-pin"], _ = json.Marshal(bad) + for name, data := range tests { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.json") + os.WriteFile(path, data, 0600) + _, sum, err := readAudit(path) + if name == "valid" { + if err != nil || !hashValid(sum) { + t.Fatalf("valid audit rejected: %v", err) + } + } else if err == nil { + t.Fatal("invalid audit accepted") + } + }) + } +} +func TestDeadlineReader(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := io.ReadAll(deadlineReader{ctx, strings.NewReader("data")}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation ignored: %v", err) + } +} + +func TestFailedPublicationEvidence(t *testing.T) { + root := bundleFixture(t) + dir := t.TempDir() + a, _ := json.Marshal(goodAudit()) + auditPath := filepath.Join(dir, "audit.json") + if err := os.WriteFile(auditPath, a, 0600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "receipt.json") + err := run(context.Background(), root, auditPath, "https://registry.example", filepath.Join(dir, "missing-key"), "base-test", out) + if err == nil { + t.Fatal("missing key accepted") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("failed publication left receipt") + } + if err := os.WriteFile(out, []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + if err := run(context.Background(), root, auditPath, "https://registry.example", "missing-key", "base-test", out); err == nil { + t.Fatal("existing receipt accepted") + } + data, _ := os.ReadFile(out) + if string(data) != "existing" { + t.Fatal("existing receipt changed") + } +} + +func bundleFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + os.Chmod(root, 0700) + platform := images.MacOSImage{HardwareModel: []byte{1}, MachineIdentifier: []byte{2}, MAC: "02:00:00:00:00:01", CPUs: 4, Memory: 8 << 30, GuestAgent: true} + data, _ := json.Marshal(platform) + for name, data := range map[string][]byte{"disk.img": []byte("disk"), "aux.img": []byte("aux"), "config.json": data} { + if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil { + t.Fatal(err) + } + } + return root +} +func TestValidateSource(t *testing.T) { + for _, name := range []string{"valid", "extra", "symlink", "public", "unmanaged", "unknown-metadata", "hardlink"} { + t.Run(name, func(t *testing.T) { + root := bundleFixture(t) + switch name { + case "extra": + os.WriteFile(filepath.Join(root, "token"), []byte("secret"), 0600) + case "symlink": + os.Remove(filepath.Join(root, "aux.img")) + os.Symlink("disk.img", filepath.Join(root, "aux.img")) + case "hardlink": + os.Remove(filepath.Join(root, "aux.img")) + os.Link(filepath.Join(root, "disk.img"), filepath.Join(root, "aux.img")) + case "public": + os.Chmod(filepath.Join(root, "disk.img"), 0644) + case "unmanaged", "unknown-metadata": + data, _ := os.ReadFile(filepath.Join(root, "config.json")) + if name == "unmanaged" { + data = []byte(strings.Replace(string(data), `"guest_agent":true`, `"guest_agent":false`, 1)) + } else { + data = []byte(strings.Replace(string(data), `"guest_agent":true`, `"guest_agent":true,"token":"secret"`, 1)) + } + os.WriteFile(filepath.Join(root, "config.json"), data, 0600) + } + err := validateSource(root) + if (err == nil) != (name == "valid") { + t.Fatalf("validation result: %v", err) + } + }) + } +} diff --git a/cmd/publish-macos-base/snapshot_darwin.go b/cmd/publish-macos-base/snapshot_darwin.go new file mode 100644 index 000000000..48e02eed5 --- /dev/null +++ b/cmd/publish-macos-base/snapshot_darwin.go @@ -0,0 +1,60 @@ +//go:build darwin && arm64 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/kernel/hypeman/lib/builds" + "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/paths" +) + +func publish(ctx context.Context, registry, secret, id, bundle string) (string, string, error) { + publisher := builds.StreamingMachinePublisher{RegistryURL: registry, Tokens: builds.NewRegistryTokenGenerator(secret)} + result, err := publisher.Publish(ctx, id, bundle) + if err != nil { + return "", "", err + } + return result.Reference, result.Digest, nil +} + +func snapshot(ctx context.Context, source, workspace string) (string, error) { + if os.Geteuid() != 0 { + return "", fmt.Errorf("root required to inspect all open storage") + } + p := paths.New(filepath.Join(workspace, "data")) + // Reuse the offline import's lsof fail-closed check and APFS clones. The + // operator must still exclude restart/writers for the whole snapshot window. + img, err := images.ImportMacOSImage(ctx, p, "localhost/macos-base:export", source) + if err != nil { + return "", err + } + ref, err := images.ParseNormalizedRef(img.Name) + if err != nil { + return "", err + } + imported := p.ImageDigestDir(ref.Repository(), img.Digest[7:]) + bundle := filepath.Join(workspace, "bundle") + if err = os.Mkdir(bundle, 0700); err != nil { + return "", err + } + for src, dst := range map[string]string{"rootfs.raw": "disk.img", "aux.img": "aux.img"} { + if err = os.Rename(filepath.Join(imported, src), filepath.Join(bundle, dst)); err != nil { + return "", err + } + } + // Canonical metadata from import; do not carry arbitrary host bundle files. + data, err := json.Marshal(img.MacOS) + if err != nil { + return "", err + } + if err = os.WriteFile(filepath.Join(bundle, "config.json"), data, 0600); err != nil { + return "", err + } + return bundle, nil +} diff --git a/cmd/publish-macos-base/snapshot_other.go b/cmd/publish-macos-base/snapshot_other.go new file mode 100644 index 000000000..8e30221fe --- /dev/null +++ b/cmd/publish-macos-base/snapshot_other.go @@ -0,0 +1,16 @@ +//go:build !darwin || !arm64 + +package main + +import ( + "context" + "fmt" +) + +func publish(context.Context, string, string, string, string) (string, string, error) { + return "", "", fmt.Errorf("base publication requires an Apple-silicon macOS operator host") +} + +func snapshot(context.Context, string, string) (string, error) { + return "", fmt.Errorf("base snapshot requires an Apple-silicon macOS operator host") +} diff --git a/docs/macos-base-images.md b/docs/macos-base-images.md new file mode 100644 index 000000000..24a84fb5d --- /dev/null +++ b/docs/macos-base-images.md @@ -0,0 +1,194 @@ +# macOS base image publication + +This is an **operator-controlled pipeline**, not an unattended Apple OS installer. +The first installation of a licensed Apple-silicon macOS guest is a prerequisite: +Setup Assistant, local account policy, OS updates, Xcode and license acceptance, +Python 3, and root GuestService provisioning. Pristine macOS is assumed to have +neither Python 3 nor full Xcode. Provision both from pinned, reviewed sources before +the audit. Command Line Tools alone do not satisfy `xcodebuild -version`. A missing +tool fails the prerequisite; the audit never installs software. + +See [Darwin GuestService](macos-guest-agent.md) and [macOS builds](macos-builds.md) +for the runtime and build contracts. The current development guest is **not** a +clean production base. No real base has been installed, sanitized, published or +boot-validated by this tooling. + +## Release flow + +1. Provision a fresh, dedicated guest. Do not sign into an Apple ID, browser, cloud + CLI or git credential helper, and do not use real accounts, passwords or tokens + in scripts, arguments, environment or shell history. +2. Run the reviewed audit through root GuestService, collect its evidence, shut the + guest down gracefully, and confirm VMM exit independently. Keep all writers + stopped until the snapshot completes. +3. Publish the offline bundle with the local administrator command below. It writes + an immutable manifest-digest receipt only after verifying registry storage. +4. Build a derivative with `POST /builds`, then push its digest with `POST /pushes`. + Each Mac pulls it with `POST /images`. Push to a candidate tag first. +5. Cold-boot and validate the candidate sequentially. After approval, push the + approved digest to the production tag. Keep the previous digest for rollback, and + have consumers pin digests, not tags. + +Identity is preserved, not rekeyed. Never boot the source guest, a derivative, or +another copy of the same identity concurrently, including on another host. Distinct +digests do not imply distinct VZ identity. CPU and memory must match the installed +base exactly; derivatives inherit them. + +## Audit (inside the guest) + +Run `scripts/macos/audit-base.py --help` for arguments. The script requires root, +arm64, an exact list of non-system accounts, a pinned root-owned non-writable agent +binary and LaunchDaemon plist, the expected single-command root agent job, and a +running launchd service. The plist must not inject environment variables. + +The host GuestService readiness gate and a successful final GuestService exec of the +script establish transport and exec. A plist flag or readiness file alone does not. +An audit run over SSH alone is not sufficient evidence for publication. + +Stage the script and baseline outside every audited home, for example under a +root-owned `/opt/base-audit`. Do not add them to the home allowlist to make an audit +pass. Pass hashes as literal GuestService exec arguments, not exported from an +interactive root shell. Close existing shells first: a shell can rewrite history on +exit after `--clean-transient` has removed it. The audit cannot detect future writers, +so keep the guest quiescent through shutdown. + +```sh +/usr/local/bin/python3 /opt/base-audit/audit-base.py \ + --agent /usr/local/sbin/hypeman-guest-agent \ + --agent-sha256 "$EXPECTED_AGENT_SHA256" \ + --plist /Library/LaunchDaemons/com.hypeman.guest-agent.plist \ + --plist-sha256 "$EXPECTED_PLIST_SHA256" \ + --users builder --home-baseline /opt/base-audit/home-baseline.json \ + --clean-transient +``` + +The account name and job path are examples. Provision the plist and binary according +to the actual agent contract first. Copy only the successful JSON stdout to a private +operator `audit.json`. Reject nonzero exit, truncated output or extra output. Record +the exec job ID, agent source commit, native cgo build command and artifact SHA256 in +the private release record. Build the Darwin agent with cgo on macOS, since a +cgo-disabled binary is not a working vsock agent. + +`--clean-transient` removes only known shell-history files. It does not make a used +guest clean. The audit rejects credential, browser, keychain, account, cloud and +runtime-state locations, unknown accounts, unknown home files, links and special +files, SSH host keys, managed-device or autologin configuration, and Time Machine +local snapshots. Missing tools, failed probes, unrecognized Xcode output and +incomplete checks fail closed. Diagnostics carry fixed failure codes only, never file +contents or command output. + +Home directories, including root and Shared, must be empty by default. A private, +reviewed `home-baseline.json` can allow specific nonsecret OS preference files by +exact absolute path and SHA256. Globs and whole directories are not allowed, and `{}` +allows nothing. Credential and browser locations stay forbidden even if listed. +Do not generate an allow-all manifest from a development guest. Review each entry for +secret or state content. Unsupported configurations fail on purpose. Extra system-account +homes or custom application state need a policy change and review before release. + +This is a policy audit, not a secret scanner or attestation over all disk bytes. It +cannot prove the absence of secrets in system files, APFS deleted blocks, other +snapshots, OS keychains, provisioning artifacts or custom paths. If the guest has ever +held real credentials, discard it and reinstall. Deletion is not sufficient. Review +OS-generated default keychains and machine identity separately, and never waive user +credentials through the baseline. + +Audit evidence records OS product and build, architecture, agent and plist hashes, +Xcode version and build, a toolchain probe hash (Xcode and clang), the baseline hash +and the script hash. It records no account lists, home paths, process output, tokens +or logs. Keep the script, baseline and toolchain inputs under version control in the +private release source. The audit is trusted operator input, not a signed guest +proof. Protect its custody and tie it to the exact guest and stop receipt. + +## Offline base to verified OCI (Mac host) + +Build the tool on the Apple-silicon operator host from the reviewed commit, after +`make build-embedded build-vz-shim` produces the real embedded binaries (this needs +the macOS SDK and cgo). Dummy embedded binaries are not acceptable. No HTTP import +endpoint is added. + +The source must be an exclusively owned, stopped, clean macvm bundle under a real +0700 directory, with exactly these nonempty private regular files: + +- `disk.img`: raw installed OS disk +- `aux.img`: VZ auxiliary storage +- `config.json`: strict `images.MacOSImage` metadata with `guest_agent: true` + +Keep audit reports, logs, credentials, provenance files and sockets out of the +bundle, and never place evidence inside an imported disk. Symlinks, aliased payload +inodes, unknown metadata and public permissions are rejected. Do any layout conversion +under operator control before the final audit and stop. + +```sh +umask 077 +go build -o ./bin/publish-macos-base ./cmd/publish-macos-base +sudo ./bin/publish-macos-base \ + --source /private/base-release/stopped-bundle \ + --audit /private/base-release/audit.json \ + --registry https://internal-registry.example \ + --registry-secret-file /private/operator/internal-registry-signing-secret \ + --id base-macos-toolchain-001 \ + --output /private/base-release/base-receipt.json \ + --timeout 2h --stopped --clean-install-reviewed +``` + +Run as root so the closed-storage probe can see other users' open files. `--stopped` +and `--clean-install-reviewed` are explicit operator assertions, not proof of sanitation +or graceful stop. For managed instances, prefer the strict stop and export path, which +checks shutdown acknowledgement and owned VMM exit under the instance lock. Offline +`lsof` detects open storage but cannot prove graceful shutdown or stop a future restart. +Never publish a bundle that another running process or API could still restart. There +is no lock shared with external VM launchers. + +The tool reuses `ImportMacOSImage`'s closed-storage check, APFS-clones into an exclusive +temporary directory, canonicalizes metadata, validates the strict snapshot and hashes +its files. It then hands off to `StreamingMachinePublisher`, so there is no second OCI +format or upload path. Import alone creates local raw-image metadata, not an OCI cache. +The publisher writes the config, layer and manifest to `builds/base-...:latest`, +verifies both stored blobs, commits the manifest last and reads it back by digest. + +The signing-secret file is local administrator access to the internal registry. It is +never guest or recipe input. It must be a private regular file of 32 to 4096 bytes, +with surrounding whitespace trimmed. The tool mints a deadline-bounded token scoped to +one repository. Never print the token or pass the secret in argv. Registry addresses +follow the publisher policy: HTTPS with system trust, or loopback HTTP. No embedded +credentials, proxies or redirects. External registry credentials belong to the +normal push API, not this command. + +The 0600 receipt is written only after verification. It holds the immutable +`registry/builds/base-...@sha256:...` reference (never `latest`), allowlisted audit +fields, the audit JSON hash, exact snapshot file hashes and a UTC timestamp. Existing +output files are never overwritten. An interrupted run can leave an unreferenced blob, +a committed candidate or a temporary directory. No receipt means unverified, not proof +that nothing was uploaded. Do not retry under a production alias. Exclude active +publication processes before cleaning up. Errors are masked on purpose, so inspect +private authenticated registry and host diagnostics instead of public CI logs. + +## Derivative build, external push and pull + +Build the derivative with `POST /builds`, passing the pinned base digest and the source +archive. Add `machine_network_mode=egress` only if the recipe downloads pinned +artifacts. The example `build.sh` in `scripts/macos/example/` needs no network. Verify the +build's `provenance` before use. + +```sh +curl -H "Authorization: Bearer $HYPEMAN_TOKEN" \ + -F "machine_base_image=registry.example/base@sha256:$BASE_DIGEST" \ + -F "source=@source.tar.gz;type=application/gzip" \ + "$HYPEMAN_URL/builds" +``` + +Push the digest with `POST /pushes`, giving the external repository and registry +credentials. Each Mac then runs `POST /images` with the digest and `platform: +darwin/arm64`. A pre-pull proves content admission, not bootability. Cold-boot the +digest on one authorized Mac and run the marker check and UID 0 exec before any +production use. + +## Remaining production gates + +- An approved fresh base installation, with its final audit and a confirmed graceful stop. +- Choice of external registry, and a test push of a 26–28 GB machine layer. +- Cold-boot validation of the promoted digest on an authorized Mac. +- Build-host disk capacity. The build path has no admission control in this change. + Run machine builds serially on a dedicated host with enough free space for the base + copy, the export and the published layer. +- Apple licence review for distributing macOS-derived images. diff --git a/scripts/macos/.gitignore b/scripts/macos/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/scripts/macos/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/macos/audit-base.py b/scripts/macos/audit-base.py new file mode 100755 index 000000000..0dbf8c9a2 --- /dev/null +++ b/scripts/macos/audit-base.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Run INSIDE a fresh provisioning guest as root, preferably via GuestService. +Never emits command output, paths, file contents, account names or environment. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import stat +import subprocess +import sys + + +class Rejected(Exception): + pass + + +def require(ok, code): + if not ok: + raise Rejected(code) + + +def command(*args): + result = subprocess.run(args, capture_output=True, timeout=30, check=False) + require(result.returncode == 0, "probe-failed") + return result.stdout.decode("utf-8", errors="strict").strip() + + +def digest(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def trusted(path): + path = Path(path) + for parent in (path, *path.parents): + s = parent.lstat() + require(not stat.S_ISLNK(s.st_mode) and s.st_uid == 0 and not s.st_mode & 0o022, + "untrusted-agent-path") + require(path.is_file(), "agent-not-regular") + + +def walk_error(_error): + raise Rejected("home-scan-failed") + + +FORBIDDEN = {".ssh", ".aws", ".azure", ".config", ".docker", ".kube", ".gnupg", + ".npmrc", ".netrc", ".git-credentials", "Keychains", "Accounts", "Cookies", + "Safari", "Google", "Chromium", "Firefox", "CloudStorage", "Mobile Documents", + ".bash_history", ".zsh_history", ".python_history", ".lesshst", ".zsh_sessions"} + + +def audit_inputs_outside_homes(homes, paths): + roots = [home.resolve(strict=True) for home in homes] + for path in paths: + resolved = Path(path).resolve(strict=True) + require(not any(resolved == home or home in resolved.parents for home in roots), + "audit-input-in-home") + + +def audit_homes(homes, baseline): + seen = set() + for home in homes: + require(home.is_dir() and not home.is_symlink(), "invalid-home") + for root, dirs, files in os.walk(home, followlinks=False, onerror=walk_error): + for name in dirs + files: + require(name not in FORBIDDEN, "credential-or-browser-state") + path = Path(root) / name + require(not path.is_symlink(), "home-link") + if path.is_dir(): + continue + require(path.is_file(), "home-special-file") + key = str(path) + require(key in baseline and digest(path) == baseline[key], "unreviewed-home-state") + seen.add(key) + require(seen == set(baseline), "baseline-mismatch") + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--agent", required=True) + p.add_argument("--agent-sha256", required=True) + p.add_argument("--plist", required=True) + p.add_argument("--plist-sha256", required=True) + p.add_argument("--users", required=True, help="exact comma-separated UID>=500 accounts, excluding nobody") + p.add_argument("--home-baseline", required=True, help="reviewed JSON: absolute home file -> SHA256; {} allows no files") + p.add_argument("--clean-transient", action="store_true", help="remove shell histories only, never credential files") + p.add_argument("--runtime-state-prefix", action="append", default=[], help="additional /private/tmp, /private/var/tmp or /private/var/run entry prefix that must not exist; repeatable") + args = p.parse_args() + require(sys.platform == "darwin" and os.geteuid() == 0, "guest-root-required") + require(command("/usr/bin/uname", "-m") == "arm64", "arm64-required") + for value in (args.agent_sha256, args.plist_sha256): + require(re.fullmatch(r"[0-9a-f]{64}", value), "invalid-pin") + trusted(args.agent) + trusted(args.plist) + require(digest(args.agent) == args.agent_sha256 and digest(args.plist) == args.plist_sha256, + "agent-pin-mismatch") + with open(args.plist, "rb") as f: + job = plistlib.load(f) + require(job.get("UserName", "root") == "root" and job.get("ProgramArguments") == [args.agent] + and not job.get("EnvironmentVariables") and not job.get("Program"), "agent-job-policy") + label = job.get("Label", "") + require(re.fullmatch(r"[a-zA-Z0-9.-]+", label), "invalid-agent-label") + job_state = command("/bin/launchctl", "print", "system/" + label) + require("state = running" in job_state and ("program = " + args.agent) in job_state, + "agent-not-running") + pid = re.search(r"\bpid = ([0-9]+)\b", job_state) + require(pid is not None, "agent-pid-missing") + process = command("/bin/ps", "-p", pid.group(1), "-o", "uid=", "-o", "comm=").split(None, 1) + require(process == ["0", args.agent], "agent-process-mismatch") + # A readiness file alone is not RPC proof. The operator must execute this + # script over GuestService and require normal instance readiness before stop. + users = command("/usr/bin/dscl", ".", "-list", "/Users", "UniqueID") + actual = {line.split()[0] for line in users.splitlines() + if int(line.split()[-1]) >= 500 and line.split()[0] != "nobody"} + require(actual == set(args.users.split(",")), "unexpected-accounts") + homes = [Path("/var/root"), Path("/Users/Shared")] + # /var is a standard macOS symlink; homes themselves must be real directories. + for user in sorted(actual): + entry = command("/usr/bin/dscl", ".", "-read", "/Users/" + user, "NFSHomeDirectory") + home = Path(entry.split(": ", 1)[1]) + require(home == Path("/Users") / user, "unexpected-home") + homes.append(home) + require({x.name for x in Path("/Users").iterdir()} <= actual | {"Shared", ".localized"}, + "unknown-home") + for home in homes: + require(home.is_dir() and not home.is_symlink(), "invalid-home") + audit_inputs_outside_homes(homes, [__file__, args.home_baseline]) + if args.clean_transient: + for home in homes: + for name in (".bash_history", ".zsh_history", ".python_history", ".lesshst"): + path = home / name + require(not path.is_symlink(), "history-link") + if path.exists(): + require(path.is_file(), "history-not-file") + path.unlink() + with open(args.home_baseline) as f: + baseline = json.load(f) + require(isinstance(baseline, dict) and all(isinstance(k, str) and isinstance(v, str) + and re.fullmatch(r"[0-9a-f]{64}", v) for k, v in baseline.items()), "invalid-baseline") + audit_homes(homes, baseline) + for root in ("/private/tmp", "/private/var/tmp", "/private/var/run"): + for item in Path(root).iterdir(): + require(not item.name.startswith(("hypeman-build-", "profile", *args.runtime_state_prefix)), "runtime-state") + require(not list(Path("/private/etc/ssh").glob("ssh_host_*")), "host-ssh-keys") + require(not Path("/Library/Preferences/com.apple.loginwindow.plist").exists(), "review-autologin-state") + require(not Path("/Library/Managed Preferences").exists(), "managed-device-state") + snapshots = command("/usr/bin/tmutil", "listlocalsnapshots", "/") + require(not any("com.apple.TimeMachine." in line for line in snapshots.splitlines()), "local-snapshots") + toolchain = command("/usr/bin/xcodebuild", "-version") + require(re.fullmatch(r"Xcode [0-9.]+\nBuild version [a-zA-Z0-9]+", toolchain), "unknown-toolchain") + tools = {"xcode": toolchain, "clang": command("/usr/bin/clang", "--version")} + report = {"version": 1, "status": "pass", "architecture": "arm64", + "os_version": command("/usr/bin/sw_vers", "-productVersion"), + "os_build": command("/usr/bin/sw_vers", "-buildVersion"), + "agent_sha256": args.agent_sha256, "plist_sha256": args.plist_sha256, + "toolchain_sha256": hashlib.sha256(json.dumps(tools, sort_keys=True).encode()).hexdigest(), + "toolchain_version": toolchain.replace("\n", "; "), + "home_baseline_sha256": digest(args.home_baseline), + "audit_script_sha256": digest(__file__)} + print(json.dumps(report, sort_keys=True)) + + +if __name__ == "__main__": + try: + main() + except Rejected as exc: + print("base audit rejected: " + str(exc), file=sys.stderr) + sys.exit(1) + except Exception: + print("base audit rejected: probe-error", file=sys.stderr) + sys.exit(1) diff --git a/scripts/macos/example/build.sh b/scripts/macos/example/build.sh new file mode 100644 index 000000000..19d8d1a22 --- /dev/null +++ b/scripts/macos/example/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +# This example performs no downloads and inherits the pinned base toolchain. +[ "$(/usr/bin/uname -m)" = arm64 ] +/usr/bin/xcodebuild -version >/dev/null +/usr/bin/install -d -m 0755 /usr/local/share/hypeman +printf '%s\n' 'macos-base-pipeline-v1' > /usr/local/share/hypeman/base-pipeline-marker +/bin/chmod 0644 /usr/local/share/hypeman/base-pipeline-marker diff --git a/scripts/macos/test_audit.py b/scripts/macos/test_audit.py new file mode 100644 index 000000000..ac6e29341 --- /dev/null +++ b/scripts/macos/test_audit.py @@ -0,0 +1,68 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(filename)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +audit = load("audit", "audit-base.py") + + +class AuditTests(unittest.TestCase): + def test_homes_fail_closed(self): + with tempfile.TemporaryDirectory() as root: + home = Path(root) + audit.audit_homes([home], {}) + private = home / "preferences" + private.write_text("never-print-this") + with self.assertRaises(audit.Rejected) as ctx: + audit.audit_homes([home], {}) + self.assertNotIn("never-print-this", str(ctx.exception)) + baseline = {str(private): audit.digest(private)} + audit.audit_homes([home], baseline) + private.write_text("changed") + with self.assertRaises(audit.Rejected): + audit.audit_homes([home], baseline) + private.unlink() + private.symlink_to("/etc/passwd") + with self.assertRaises(audit.Rejected): + audit.audit_homes([home], baseline) + + def test_credentials_cannot_be_baselined(self): + with tempfile.TemporaryDirectory() as root: + home = Path(root) + credentials = home / ".netrc" + credentials.write_text("never-print-this") + with self.assertRaises(audit.Rejected): + audit.audit_homes([home], {str(credentials): audit.digest(credentials)}) + with patch.object(audit.os, "walk", side_effect=lambda *args, **kwargs: kwargs["onerror"](OSError())): + with tempfile.TemporaryDirectory() as root: + with self.assertRaises(audit.Rejected): + audit.audit_homes([Path(root)], {}) + + def test_audit_inputs_outside_homes(self): + with tempfile.TemporaryDirectory() as root: + home = Path(root) / "home" + home.mkdir() + outside = Path(root) / "audit.py" + outside.write_text("reviewed") + audit.audit_inputs_outside_homes([home], [outside]) + inside = home / "baseline.json" + inside.write_text("{}") + with self.assertRaisesRegex(audit.Rejected, "audit-input-in-home"): + audit.audit_inputs_outside_homes([home], [inside]) + alias = Path(root) / "alias.json" + alias.symlink_to(inside) + with self.assertRaisesRegex(audit.Rejected, "audit-input-in-home"): + audit.audit_inputs_outside_homes([home], [alias]) + + +if __name__ == "__main__": + unittest.main()