@homeflare/alchemy 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +14 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +12 -5
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks sudo-runner.ts makes against the host, as the deploying user, before it runs anything
|
|
3
|
+
* as root. sudo-allowlist.ts checks the argv; this checks what the paths in it actually are.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT IS LOGGED IS WHAT IS WRITTEN. A symlinked directory under a prefix would make
|
|
6
|
+
* `install` or `rm`, running as root, act on a path the log never named. So every directory
|
|
7
|
+
* between the prefix and the file must be a real directory.
|
|
8
|
+
* ⛔ EVERY DIRECTORY FROM THE PREFIX DOWN IS ROOT'S ALONE (decided 2026-09-21). A directory below
|
|
9
|
+
* the prefix that another user owns, or that group or other may write, lets that user swap what
|
|
10
|
+
* lies under it — a symlink where the file was — between these checks and the privileged call.
|
|
11
|
+
* So each one must be owned by root and not group/other-writable (rootOnlyProblem), exactly as
|
|
12
|
+
* the prefix itself must.
|
|
13
|
+
* ⛔ AND EVERY DIRECTORY ABOVE THE PREFIX (red team, 2026-09-21). Whoever may write the prefix's
|
|
14
|
+
* parent may rename the prefix away and put a symlink in its place between these checks and the
|
|
15
|
+
* privileged call — the same swap one level up. So `/` down to the prefix's parent must be
|
|
16
|
+
* root-only too (a root-owned symlink among them, `/etc` -> `private/etc`, is root's own and is
|
|
17
|
+
* followed; its target is root's choice, and not walked).
|
|
18
|
+
* ⛔ AND NO ACL THAT SAYS OTHERWISE (sudo-acl.ts): mode bits are not the whole answer on macOS.
|
|
19
|
+
* ⚠️ These checks run before the privileged call, as the deploying user. They guard against a
|
|
20
|
+
* mistaken declaration and against every user but root; root itself could still change a path
|
|
21
|
+
* in that window.
|
|
22
|
+
*/
|
|
23
|
+
import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
24
|
+
import { assertNoAclGrants } from './sudo-acl.ts';
|
|
25
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
26
|
+
|
|
27
|
+
const refuse = (path: string, message: string): Error =>
|
|
28
|
+
new SudoRefusedError(`sudoRunner ${path}: ${message}`);
|
|
29
|
+
|
|
30
|
+
/** The directories strictly between `prefix` and the file at `path`. */
|
|
31
|
+
export const betweenDirs = (prefix: string, path: string): string[] => {
|
|
32
|
+
const parts = path
|
|
33
|
+
.slice(prefix.length + 1)
|
|
34
|
+
.split('/')
|
|
35
|
+
.slice(0, -1);
|
|
36
|
+
return parts.map((_, index) => `${prefix}/${parts.slice(0, index + 1).join('/')}`);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The directories strictly above `prefix`: `/`, then each one down to the prefix's parent. */
|
|
40
|
+
export const aboveDirs = (prefix: string): string[] => {
|
|
41
|
+
const parts = prefix.split('/').slice(1, -1);
|
|
42
|
+
return ['/', ...parts.map((_, index) => `/${parts.slice(0, index + 1).join('/')}`)];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What must be at `path` itself: a regular file, or (for a write or a remove) nothing yet.
|
|
47
|
+
* ⚠️ A DIRECTORY AT THE DESTINATION IS THE TRAP: `install src dir` copies INTO the directory
|
|
48
|
+
* (install(1)), and install decides that with stat, so a symlink to a directory does the same.
|
|
49
|
+
*/
|
|
50
|
+
export type Expect = 'file' | 'file-or-absent';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ⛔ THE PREFIX, AND EVERY DIRECTORY BELOW IT ON THE WAY TO THE FILE, MUST BE A DIRECTORY ONLY ROOT
|
|
54
|
+
* CAN CHANGE, checked at every call rather than trusted from the declaration. A symlinked prefix makes root write wherever it points, which the
|
|
55
|
+
* log never names (`/opt/example` -> `/etc` turns a HostFile at `/opt/example/sudoers` into
|
|
56
|
+
* `/etc/sudoers`). A prefix another user owns or may write lets that user swap a directory below
|
|
57
|
+
* it for a symlink between this check and the privileged call.
|
|
58
|
+
* ⚠️ lstat of the prefix itself: a symlink ABOVE it (`/etc` -> `/private/etc` on macOS) is the
|
|
59
|
+
* system's and is followed, as every path lookup does — when root owns it (aboveDirs).
|
|
60
|
+
* ★ MEASURED 2026-09-21 on macOS 27.2 (`ls -ldn`): /Library/LaunchDaemons, /private/etc, /opt and
|
|
61
|
+
* /usr/local are uid 0, mode 0755, so the prefixes a host stack declares pass as they are.
|
|
62
|
+
*/
|
|
63
|
+
const rootOnlyProblem = (stat: FileStat | undefined): string | undefined => {
|
|
64
|
+
if (stat === undefined) return 'is missing';
|
|
65
|
+
if (stat.kind !== 'directory') return `is a ${stat.kind}, not a directory`;
|
|
66
|
+
if (stat.uid !== 0) return `is owned by uid ${String(stat.uid)}, not root`;
|
|
67
|
+
if ((stat.mode & 0o022) !== 0) {
|
|
68
|
+
return `is writable by group or other (mode ${stat.mode.toString(8).padStart(4, '0')})`;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Refuse a prefix that is not a root-only directory, a symlink or a non-directory between the
|
|
75
|
+
* prefix and `path`, and anything but a file at it.
|
|
76
|
+
*/
|
|
77
|
+
export const assertPlainPath = async (
|
|
78
|
+
base: HostRunner,
|
|
79
|
+
prefix: string,
|
|
80
|
+
path: string,
|
|
81
|
+
expect: Expect,
|
|
82
|
+
): Promise<'file' | 'absent'> => {
|
|
83
|
+
const above = aboveDirs(prefix);
|
|
84
|
+
for (const [index, stat] of (await Promise.all(above.map((dir) => base.stat(dir)))).entries()) {
|
|
85
|
+
const up = stat?.kind === 'symlink' && stat.uid === 0 ? undefined : rootOnlyProblem(stat);
|
|
86
|
+
if (up !== undefined) {
|
|
87
|
+
throw refuse(
|
|
88
|
+
path,
|
|
89
|
+
`${String(above[index])}, above the declared prefix ${prefix}, ${up}. Whoever may change it ` +
|
|
90
|
+
'may swap the prefix itself; every directory from / down must be root-only. Nothing ran as root.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const dirs = betweenDirs(prefix, path);
|
|
95
|
+
const [prefixStat, ...stats] = await Promise.all([prefix, ...dirs].map((dir) => base.stat(dir)));
|
|
96
|
+
const bad = rootOnlyProblem(prefixStat);
|
|
97
|
+
if (bad !== undefined) {
|
|
98
|
+
throw refuse(
|
|
99
|
+
path,
|
|
100
|
+
`the declared prefix ${prefix} ${bad}. Declare only a real directory that root owns and ` +
|
|
101
|
+
'only root may write. Nothing ran as root.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
for (const [index, stat] of stats.entries()) {
|
|
105
|
+
const dir = String(dirs[index]);
|
|
106
|
+
if (stat?.kind !== 'directory') {
|
|
107
|
+
throw refuse(
|
|
108
|
+
path,
|
|
109
|
+
`${dir} is ${stat === undefined ? 'missing' : `a ${stat.kind}`}; root ` +
|
|
110
|
+
'writes only through real directories under a declared prefix.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const below = rootOnlyProblem(stat);
|
|
114
|
+
if (below !== undefined) {
|
|
115
|
+
throw refuse(
|
|
116
|
+
path,
|
|
117
|
+
`${dir} ${below}. Every directory from the prefix ${prefix} down to the file must be one ` +
|
|
118
|
+
'that root owns and only root may write. Nothing ran as root.',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await assertNoAclGrants(base, path, [...above, prefix, ...dirs]);
|
|
123
|
+
const target = await base.stat(path);
|
|
124
|
+
if (target === undefined && expect === 'file-or-absent') return 'absent';
|
|
125
|
+
if (target?.kind === 'file') return 'file';
|
|
126
|
+
throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ⛔ A ROOT-OWNED FILE UNDER A PREFIX IS ROOT'S TO CHANGE, AND ONLY ROOT'S (decided 2026-09-21).
|
|
131
|
+
* Group- or world-writable, anyone in that class rewrites a file root installed — a daemon's
|
|
132
|
+
* config, a script a LaunchDaemon runs as root — which is root by another name. Setuid or setgid,
|
|
133
|
+
* it runs as root (or its group) for whoever executes it. install(1) would apply either, as root.
|
|
134
|
+
* ★ Root-owned is an omitted owner (the runner installs as root under a prefix) or uid 0. A file
|
|
135
|
+
* handed to another user is that user's to change; the sticky bit changes nothing on a file.
|
|
136
|
+
*/
|
|
137
|
+
export const modeProblem = (options: WriteOptions): string | undefined => {
|
|
138
|
+
if (options.uid !== undefined && options.uid !== 0) return undefined;
|
|
139
|
+
const found: string[] = [];
|
|
140
|
+
if ((options.mode & 0o6000) !== 0) found.push('setuid/setgid');
|
|
141
|
+
if ((options.mode & 0o022) !== 0) found.push('writable by group or other');
|
|
142
|
+
if (found.length === 0) return undefined;
|
|
143
|
+
return `mode ${options.mode.toString(8).padStart(4, '0')} is ${found.join(' and ')} on a root-owned file`;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
|
|
148
|
+
* path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
|
|
149
|
+
* user — so sudoRunner's `checkWrite` runs it at PLAN time too, and a plan still never calls sudo.
|
|
150
|
+
*/
|
|
151
|
+
export const assertInstallable = async (
|
|
152
|
+
base: HostRunner,
|
|
153
|
+
prefix: string,
|
|
154
|
+
path: string,
|
|
155
|
+
options: WriteOptions,
|
|
156
|
+
groups: readonly number[] | undefined,
|
|
157
|
+
): Promise<void> => {
|
|
158
|
+
const mode = modeProblem(options);
|
|
159
|
+
if (mode !== undefined) {
|
|
160
|
+
throw refuse(
|
|
161
|
+
path,
|
|
162
|
+
`${mode}. Declare a mode without those bits (0644, 0640, 0755), or give the file to its ` +
|
|
163
|
+
'real owner. Nothing ran as root.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
167
|
+
await assertReadableBack(base, path, options, groups);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* POSIX read permission for one uid: owner class, else group class, else other. `undefined` when
|
|
172
|
+
* only group membership could decide and the groups are unknown.
|
|
173
|
+
* ⚠️ EXACTLY ONE CLASS APPLIES. A group member gets the group bits even when the other bits are
|
|
174
|
+
* wider (`0604` hides a file from its group), so "other can read" is not enough on its own.
|
|
175
|
+
*/
|
|
176
|
+
export const canRead = (
|
|
177
|
+
file: { readonly uid: number; readonly gid: number; readonly mode: number },
|
|
178
|
+
uid: number,
|
|
179
|
+
groups: readonly number[] | undefined,
|
|
180
|
+
): boolean | undefined => {
|
|
181
|
+
if (uid === 0) return true;
|
|
182
|
+
if (file.uid === uid) return (file.mode & 0o400) !== 0;
|
|
183
|
+
const groupRead = (file.mode & 0o040) !== 0;
|
|
184
|
+
const otherRead = (file.mode & 0o004) !== 0;
|
|
185
|
+
// Membership cannot change the answer when both classes agree.
|
|
186
|
+
if (groupRead === otherRead) return groupRead;
|
|
187
|
+
if (groups === undefined) return undefined;
|
|
188
|
+
return groups.includes(file.gid) ? groupRead : otherRead;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* ⛔ REFUSE A FILE THE DEPLOYING USER COULD NOT READ BACK, before writing it. Both providers read
|
|
193
|
+
* every write back to verify it (host-file-lifecycle.ts, job-lifecycle.ts) and read it again at
|
|
194
|
+
* every plan, as the deploying user; this runner elevates writes, never reads. A root-only
|
|
195
|
+
* `0600` would be installed, then fail the read-back with EACCES and stay on disk with no state,
|
|
196
|
+
* so every later plan would fail too.
|
|
197
|
+
* ★ An omitted owner is root (under a prefix the runner writes as root). An omitted group is the
|
|
198
|
+
* parent directory's, which is the group a new file gets on macOS.
|
|
199
|
+
* ⚠️ Unknown groups never refuse: the read-back after the write stays the final judge.
|
|
200
|
+
*/
|
|
201
|
+
export const assertReadableBack = async (
|
|
202
|
+
base: HostRunner,
|
|
203
|
+
path: string,
|
|
204
|
+
options: WriteOptions,
|
|
205
|
+
groups: readonly number[] | undefined,
|
|
206
|
+
): Promise<void> => {
|
|
207
|
+
const operator = base.effectiveUid();
|
|
208
|
+
const parent = path.slice(0, path.lastIndexOf('/')) || '/';
|
|
209
|
+
const gid = options.gid ?? (await base.stat(parent))?.gid ?? 0;
|
|
210
|
+
const file = { gid, mode: options.mode, uid: options.uid ?? 0 };
|
|
211
|
+
if (canRead(file, operator, groups) !== false) return;
|
|
212
|
+
throw refuse(
|
|
213
|
+
path,
|
|
214
|
+
`owner ${String(file.uid)}, group ${String(gid)}, mode ${file.mode.toString(8)} would hide it ` +
|
|
215
|
+
`from the deploying user (uid ${String(operator)}), who reads every file back. Declare a ` +
|
|
216
|
+
'mode that user can read, or deploy as root. Nothing was written.',
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The deploying user's groups, from `id -G`; `undefined` when that fails.
|
|
222
|
+
* ⚠️ NOT process.getgroups(). MEASURED 2026-09-21 on macOS 27.2: getgroups() returned 16 groups
|
|
223
|
+
* where `id -G` returned 18, because macOS caps a process's own list at 16. A group past the
|
|
224
|
+
* 16th would read as "not a member" and refuse a file the user can in fact read.
|
|
225
|
+
*/
|
|
226
|
+
export const operatorGroups = async (base: HostRunner): Promise<readonly number[] | undefined> => {
|
|
227
|
+
const result = await base.exec(['/usr/bin/id', '-G']);
|
|
228
|
+
const ids = result.stdout.trim().split(/\s+/);
|
|
229
|
+
return result.exitCode === 0 && ids.every((id) => /^\d+$/.test(id)) ? ids.map(Number) : undefined;
|
|
230
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real LaunchdJob and HostFile lifecycles, driven through sudoRunner over the fake host: the
|
|
3
|
+
* deploying user is uid 501, and the only way anything root-owned changes is an allowlisted
|
|
4
|
+
* `sudo -n` call. No real sudo, launchctl or /Library write happens.
|
|
5
|
+
*
|
|
6
|
+
* ★ The assertions are on the PRIVILEGED SEQUENCE, because that list is the whole point: a plan
|
|
7
|
+
* needs none, a create is install + bootstrap, a delete is bootout + rm.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { fakeSudoHost } from './fake-sudo.ts';
|
|
11
|
+
import { deleteFile, reconcileFile } from './host-file-lifecycle.ts';
|
|
12
|
+
import type { LaunchdJobAttributes, LaunchdJobProps } from './job-form.ts';
|
|
13
|
+
import { deleteJob, diffJob, readJob, reconcileJob } from './job-lifecycle.ts';
|
|
14
|
+
import { INSTALL, RM } from './sudo-allowlist.ts';
|
|
15
|
+
|
|
16
|
+
const DAEMON = '/Library/LaunchDaemons/com.example.job.plist';
|
|
17
|
+
const job: LaunchdJobProps = {
|
|
18
|
+
domain: 'system',
|
|
19
|
+
keepAlive: true,
|
|
20
|
+
label: 'com.example.job',
|
|
21
|
+
programArguments: ['/usr/local/bin/job'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Each privileged call as its program and first operand, e.g. `launchctl bootout`. */
|
|
25
|
+
const fail = (): never => {
|
|
26
|
+
throw new Error('the deployed plist is missing');
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const verbs = (privileged: string[][]) =>
|
|
30
|
+
privileged.map(([program, first]) =>
|
|
31
|
+
program === INSTALL ? 'install' : program === RM ? 'rm' : `launchctl ${String(first)}`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe('a system daemon, deployed as the operator', () => {
|
|
35
|
+
test('create is install then bootstrap; the plist lands root:wheel 0644', async () => {
|
|
36
|
+
const host = fakeSudoHost();
|
|
37
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
38
|
+
expect(verbs(host.privileged())).toEqual(['install', 'launchctl bootstrap']);
|
|
39
|
+
expect(host.fake.files.get(DAEMON)).toMatchObject({ gid: 0, mode: 0o644, uid: 0 });
|
|
40
|
+
expect(attrs).toMatchObject({ loaded: true, plistPath: DAEMON });
|
|
41
|
+
expect(host.logs).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a plan (read, diff) needs no sudo at all', async () => {
|
|
45
|
+
const host = fakeSudoHost();
|
|
46
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
47
|
+
const before = host.sudoCalls.length;
|
|
48
|
+
expect(await readJob(host.runner, job)).toMatchObject({ loaded: true });
|
|
49
|
+
expect(await diffJob(host.runner, job, attrs)).toEqual({ action: 'noop' });
|
|
50
|
+
expect(await diffJob(host.runner, { ...job, runAtLoad: true }, attrs)).toEqual({
|
|
51
|
+
action: 'update',
|
|
52
|
+
});
|
|
53
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('update is install, bootout, bootstrap — in that order', async () => {
|
|
57
|
+
const host = fakeSudoHost();
|
|
58
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
59
|
+
await reconcileJob(host.runner, { ...job, runAtLoad: true }, attrs);
|
|
60
|
+
expect(verbs(host.privileged())).toEqual([
|
|
61
|
+
'install',
|
|
62
|
+
'launchctl bootstrap',
|
|
63
|
+
'install',
|
|
64
|
+
'launchctl bootout',
|
|
65
|
+
'launchctl bootstrap',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('delete is bootout then rm', async () => {
|
|
70
|
+
const host = fakeSudoHost();
|
|
71
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
72
|
+
await deleteJob(host.runner, attrs);
|
|
73
|
+
expect(verbs(host.privileged()).slice(2)).toEqual(['launchctl bootout', 'rm']);
|
|
74
|
+
expect(host.fake.files.has(DAEMON)).toBe(false);
|
|
75
|
+
expect(host.fake.loaded.has('system/com.example.job')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('sudo wanting a password on an update leaves the old job running, untouched', async () => {
|
|
79
|
+
const host = fakeSudoHost();
|
|
80
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
81
|
+
const pid = host.fake.loaded.get('system/com.example.job');
|
|
82
|
+
const before = host.fake.files.get(DAEMON)?.bytes;
|
|
83
|
+
host.state.sudo = 'password';
|
|
84
|
+
await expect(reconcileJob(host.runner, { ...job, runAtLoad: true }, attrs)).rejects.toThrow(
|
|
85
|
+
'a password is required',
|
|
86
|
+
);
|
|
87
|
+
expect(host.fake.loaded.get('system/com.example.job')).toBe(pid);
|
|
88
|
+
expect(host.fake.files.get(DAEMON)?.bytes).toBe(before);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('without /Library/LaunchDaemons declared, a delete is refused before the bootout', async () => {
|
|
93
|
+
const deployed = fakeSudoHost();
|
|
94
|
+
const attrs = await reconcileJob(deployed.runner, job, undefined);
|
|
95
|
+
// The same host, now driven by a runner that declares only /opt/example.
|
|
96
|
+
const narrow = fakeSudoHost([20, 80], ['/opt/example']);
|
|
97
|
+
narrow.fake.files.set(DAEMON, deployed.fake.files.get(DAEMON) ?? fail());
|
|
98
|
+
narrow.fake.loaded.set('system/com.example.job', 4242);
|
|
99
|
+
await expect(deleteJob(narrow.runner, attrs)).rejects.toThrow('/Library/LaunchDaemons');
|
|
100
|
+
// ⚠️ Not half a delete: the job still runs and its plist is still there, both or neither.
|
|
101
|
+
expect(narrow.fake.loaded.get('system/com.example.job')).toBe(4242);
|
|
102
|
+
expect(narrow.fake.files.has(DAEMON)).toBe(true);
|
|
103
|
+
expect(narrow.sudoCalls).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("another user's gui domain is not elevated", () => {
|
|
107
|
+
const theirs: LaunchdJobProps = { ...job, domain: 'gui/502' };
|
|
108
|
+
|
|
109
|
+
test('create is refused at the write, with nothing written or loaded', async () => {
|
|
110
|
+
const host = fakeSudoHost();
|
|
111
|
+
await expect(reconcileJob(host.runner, theirs, undefined)).rejects.toThrow(
|
|
112
|
+
'owner 502 needs root',
|
|
113
|
+
);
|
|
114
|
+
expect(host.fake.files.size).toBe(0);
|
|
115
|
+
expect(host.sudoCalls).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('delete is refused at the bootout, and their job keeps running', async () => {
|
|
119
|
+
const host = fakeSudoHost();
|
|
120
|
+
host.fake.loaded.set('gui/502/com.example.job', 4242);
|
|
121
|
+
const output: LaunchdJobAttributes = {
|
|
122
|
+
domain: 'gui/502',
|
|
123
|
+
label: 'com.example.job',
|
|
124
|
+
loaded: true,
|
|
125
|
+
plistPath: '/Users/other/Library/LaunchAgents/com.example.job.plist',
|
|
126
|
+
plistSha256: '',
|
|
127
|
+
serviceTarget: 'gui/502/com.example.job',
|
|
128
|
+
};
|
|
129
|
+
await expect(deleteJob(host.runner, output)).rejects.toThrow('elevates only the system domain');
|
|
130
|
+
expect(host.fake.loaded.get('gui/502/com.example.job')).toBe(4242);
|
|
131
|
+
expect(host.sudoCalls).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('a root-owned HostFile under a declared prefix', () => {
|
|
136
|
+
const PATH = '/opt/example/app/exporter.yml';
|
|
137
|
+
const file = {
|
|
138
|
+
content: 'listen: 127.0.0.1:9100\n',
|
|
139
|
+
group: 80,
|
|
140
|
+
mode: 0o640,
|
|
141
|
+
owner: 0,
|
|
142
|
+
path: PATH,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
test('is installed with its owner, group and mode, verified by the read-back', async () => {
|
|
146
|
+
const host = fakeSudoHost();
|
|
147
|
+
const attrs = await reconcileFile(host.runner, file);
|
|
148
|
+
expect(attrs).toMatchObject({ gid: 80, mode: 0o640, uid: 0 });
|
|
149
|
+
expect(host.privileged()[0]?.slice(0, 8)).toEqual([
|
|
150
|
+
INSTALL,
|
|
151
|
+
'-S',
|
|
152
|
+
'-m',
|
|
153
|
+
'0640',
|
|
154
|
+
'-o',
|
|
155
|
+
'0',
|
|
156
|
+
'-g',
|
|
157
|
+
'80',
|
|
158
|
+
]);
|
|
159
|
+
await deleteFile(host.runner, attrs);
|
|
160
|
+
expect(verbs(host.privileged())).toEqual(['install', 'rm']);
|
|
161
|
+
expect(host.fake.files.has(PATH)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a mode the operator could not read back is refused with nothing written', async () => {
|
|
165
|
+
const host = fakeSudoHost();
|
|
166
|
+
await expect(reconcileFile(host.runner, { ...file, group: 0, mode: 0o600 })).rejects.toThrow(
|
|
167
|
+
'Nothing was written',
|
|
168
|
+
);
|
|
169
|
+
expect(host.fake.files.has(PATH)).toBe(false);
|
|
170
|
+
expect(host.sudoCalls).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner's 2026-09-21 refusals, over the fake host (fake-sudo.ts): no group/world-writable,
|
|
3
|
+
* setuid or setgid root-owned file under a prefix, and no directory from the prefix down that root
|
|
4
|
+
* does not own alone. Each is refused before anything is staged or run as root — and, through
|
|
5
|
+
* `checkWrite`, at PLAN time, still without sudo.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { OPERATOR, fakeSudoHost } from './fake-sudo.ts';
|
|
9
|
+
import { diffFile, reconcileFile } from './host-file-lifecycle.ts';
|
|
10
|
+
import type { LaunchdJobProps } from './job-form.ts';
|
|
11
|
+
import { diffJob, reconcileJob } from './job-lifecycle.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
import { modeProblem } from './sudo-guard.ts';
|
|
14
|
+
|
|
15
|
+
const CONF = '/opt/example/app/a.conf';
|
|
16
|
+
const bytes = (text: string) => new TextEncoder().encode(text);
|
|
17
|
+
type Host = ReturnType<typeof fakeSudoHost>;
|
|
18
|
+
const dir = (mode: number, uid = 0) => ({
|
|
19
|
+
bytes: bytes(''),
|
|
20
|
+
gid: 0,
|
|
21
|
+
kind: 'directory' as const,
|
|
22
|
+
mode,
|
|
23
|
+
uid,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('modeProblem', () => {
|
|
27
|
+
test.each([
|
|
28
|
+
['0644 root', { mode: 0o644, uid: 0 }, undefined],
|
|
29
|
+
['0755 with the owner omitted (root under a prefix)', { mode: 0o755 }, undefined],
|
|
30
|
+
['0640 root', { mode: 0o640, uid: 0 }, undefined],
|
|
31
|
+
['sticky 1755 changes nothing on a file', { mode: 0o1755 }, undefined],
|
|
32
|
+
['0664 root is group-writable', { mode: 0o664 }, 'writable by group or other'],
|
|
33
|
+
['0646 root is world-writable', { mode: 0o646, uid: 0 }, 'writable by group or other'],
|
|
34
|
+
['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
|
|
35
|
+
['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
|
|
36
|
+
['6777 root is both', { mode: 0o6777 }, 'setuid/setgid and writable by group or other'],
|
|
37
|
+
['0664 handed to another user is theirs to change', { mode: 0o664, uid: OPERATOR }, undefined],
|
|
38
|
+
] as const)('%s', (_name, options, found) => {
|
|
39
|
+
const problem = modeProblem(options);
|
|
40
|
+
if (found === undefined) expect(problem).toBeUndefined();
|
|
41
|
+
else expect(problem).toContain(found);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('a root-owned install under a prefix', () => {
|
|
46
|
+
test.each([0o664, 0o646, 0o4755, 0o2755])(
|
|
47
|
+
'mode %o is refused before staging or sudo',
|
|
48
|
+
async (mode) => {
|
|
49
|
+
const host = fakeSudoHost();
|
|
50
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode });
|
|
51
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
52
|
+
await expect(write).rejects.toThrow('on a root-owned file');
|
|
53
|
+
expect(host.state.stagedCount).toBe(0);
|
|
54
|
+
expect(host.sudoCalls).toEqual([]);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
test('an omitted-owner 0644 still installs, as root', async () => {
|
|
59
|
+
const host = fakeSudoHost();
|
|
60
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
61
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('every directory from the prefix down to the file', () => {
|
|
66
|
+
const below: [string, (h: Host) => void, string][] = [
|
|
67
|
+
['owned by the operator', (h) => h.fake.dirs.set('/opt/example/app', OPERATOR), 'uid 501'],
|
|
68
|
+
['group-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o775)), '0775'],
|
|
69
|
+
['world-writable', (h) => h.fake.files.set('/opt/example/app', dir(0o757)), '0757'],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
test.each(below)('%s is refused for a write, before staging or sudo', async (_n, setup, says) => {
|
|
73
|
+
const host = fakeSudoHost();
|
|
74
|
+
setup(host);
|
|
75
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
76
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
77
|
+
await expect(write).rejects.toThrow(
|
|
78
|
+
/\/opt\/example\/app is .*(uid 501|0775|0757).*Every directory from the prefix/s,
|
|
79
|
+
);
|
|
80
|
+
await expect(write).rejects.toThrow(says);
|
|
81
|
+
expect(host.state.stagedCount).toBe(0);
|
|
82
|
+
expect(host.sudoCalls).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test.each(below)('%s is refused for a remove too', async (_n, setup, says) => {
|
|
86
|
+
const host = fakeSudoHost();
|
|
87
|
+
host.fake.files.set(CONF, { ...dir(0o644), kind: 'file' });
|
|
88
|
+
setup(host);
|
|
89
|
+
await expect(host.runner.removeFile(CONF)).rejects.toThrow(says);
|
|
90
|
+
expect(host.sudoCalls).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('at plan time, through checkWrite — reads only, never sudo', () => {
|
|
95
|
+
test('HostFile: a diff that would write a setuid root file fails the plan', async () => {
|
|
96
|
+
const host = fakeSudoHost();
|
|
97
|
+
const output = await reconcileFile(host.runner, { content: 'x', mode: 0o755, path: CONF });
|
|
98
|
+
const before = host.sudoCalls.length;
|
|
99
|
+
const plan = diffFile(host.runner, { content: 'x', mode: 0o4755, path: CONF }, output);
|
|
100
|
+
await expect(plan).rejects.toThrow('setuid/setgid');
|
|
101
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
102
|
+
// ★ A converged file plans noop and asks nothing, so an unrelated plan is never blocked by it.
|
|
103
|
+
expect(await diffFile(host.runner, { content: 'x', mode: 0o755, path: CONF }, output)).toEqual({
|
|
104
|
+
action: 'noop',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('HostFile: a new path under a directory another user may write fails the plan', async () => {
|
|
109
|
+
const host = fakeSudoHost();
|
|
110
|
+
const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
|
|
111
|
+
host.fake.dirs.set('/opt/example/other', OPERATOR);
|
|
112
|
+
const moved = { content: 'x', path: '/opt/example/other/a.conf' };
|
|
113
|
+
await expect(diffFile(host.runner, moved, output)).rejects.toThrow('uid 501');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test.each([
|
|
117
|
+
['an update', { runAtLoad: true }],
|
|
118
|
+
['a rename (the delete-first replace)', { label: 'com.example.renamed' }],
|
|
119
|
+
] as const)(
|
|
120
|
+
'LaunchdJob: %s into a group-writable LaunchDaemons fails the plan',
|
|
121
|
+
async (_n, change) => {
|
|
122
|
+
const job: LaunchdJobProps = {
|
|
123
|
+
domain: 'system',
|
|
124
|
+
label: 'com.example.job',
|
|
125
|
+
programArguments: ['/usr/local/bin/job'],
|
|
126
|
+
};
|
|
127
|
+
const host = fakeSudoHost();
|
|
128
|
+
const attrs = await reconcileJob(host.runner, job, undefined);
|
|
129
|
+
host.fake.files.set('/Library/LaunchDaemons', dir(0o775));
|
|
130
|
+
const before = host.sudoCalls.length;
|
|
131
|
+
await expect(diffJob(host.runner, { ...job, ...change }, attrs)).rejects.toThrow('0775');
|
|
132
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|