@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The allowlist, as pure argv checks. Each refusal here is a shape that would do real damage as
|
|
3
|
+
* root; each acceptance is a shape the providers or the runner actually produce.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
7
|
+
import {
|
|
8
|
+
INSTALL,
|
|
9
|
+
RM,
|
|
10
|
+
checkPrefixes,
|
|
11
|
+
octalMode,
|
|
12
|
+
prefixOf,
|
|
13
|
+
privilegedProblem,
|
|
14
|
+
routeExec,
|
|
15
|
+
} from './sudo-allowlist.ts';
|
|
16
|
+
|
|
17
|
+
const DAEMONS = '/Library/LaunchDaemons';
|
|
18
|
+
const context = { prefixes: [DAEMONS, '/opt/example'], staged: '/tmp/hf-sudo-x/staged' };
|
|
19
|
+
const allowed = (argv: string[]) => privilegedProblem(argv, context);
|
|
20
|
+
const PLIST = `${DAEMONS}/com.example.a.plist`;
|
|
21
|
+
|
|
22
|
+
describe('checkPrefixes', () => {
|
|
23
|
+
test.each([
|
|
24
|
+
[[], 'no prefixes'],
|
|
25
|
+
[['/'], 'every path'],
|
|
26
|
+
[['opt/example'], 'absolute'],
|
|
27
|
+
[['/opt/example/'], 'normalised'],
|
|
28
|
+
[['/opt/../etc'], 'normalised'],
|
|
29
|
+
])('refuses %j', (prefixes, message) => {
|
|
30
|
+
expect(() => checkPrefixes(prefixes)).toThrow(message);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('keeps a valid list', () => {
|
|
34
|
+
expect(checkPrefixes([DAEMONS])).toEqual([DAEMONS]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('prefixOf', () => {
|
|
39
|
+
test('strictly under, never a sibling with the same leading text, never the prefix itself', () => {
|
|
40
|
+
expect(prefixOf('/opt/example/a.conf', context.prefixes)).toBe('/opt/example');
|
|
41
|
+
expect(prefixOf('/opt/example-other/a.conf', context.prefixes)).toBeUndefined();
|
|
42
|
+
expect(prefixOf('/opt/example', context.prefixes)).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a path that walks out with .. is not under anything', () => {
|
|
46
|
+
expect(prefixOf('/opt/example/../../etc/sudoers', context.prefixes)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('launchctl shapes', () => {
|
|
51
|
+
test.each([
|
|
52
|
+
[[LAUNCHCTL, 'bootstrap', 'system', PLIST]],
|
|
53
|
+
[[LAUNCHCTL, 'bootout', 'system/com.example.a']],
|
|
54
|
+
[[LAUNCHCTL, 'kickstart', 'system/com.example.a']],
|
|
55
|
+
[[LAUNCHCTL, 'kickstart', '-k', '-p', 'system/com.example.a']],
|
|
56
|
+
])('allows %j', (argv) => {
|
|
57
|
+
expect(allowed(argv)).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test.each([
|
|
61
|
+
// ⛔ launchctl(1): a bare domain removes the whole system domain.
|
|
62
|
+
['bootout of the bare system domain', [LAUNCHCTL, 'bootout', 'system']],
|
|
63
|
+
['bootout by domain plus path', [LAUNCHCTL, 'bootout', 'system', PLIST]],
|
|
64
|
+
// ⛔ launchctl(1): a directory bootstraps every plist in it.
|
|
65
|
+
['bootstrap of a directory', [LAUNCHCTL, 'bootstrap', 'system', DAEMONS]],
|
|
66
|
+
['bootstrap of two plists', [LAUNCHCTL, 'bootstrap', 'system', PLIST, PLIST]],
|
|
67
|
+
[
|
|
68
|
+
'bootstrap outside every prefix',
|
|
69
|
+
[LAUNCHCTL, 'bootstrap', 'system', '/tmp/com.example.a.plist'],
|
|
70
|
+
],
|
|
71
|
+
['bootstrap of a non-plist', [LAUNCHCTL, 'bootstrap', 'system', `${DAEMONS}/com.example.a`]],
|
|
72
|
+
// ★ Only where launchd loads daemons at boot: anywhere else is a job that vanishes on restart.
|
|
73
|
+
[
|
|
74
|
+
'bootstrap from another prefix',
|
|
75
|
+
[LAUNCHCTL, 'bootstrap', 'system', '/opt/example/com.example.a.plist'],
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
'bootstrap from a subdirectory',
|
|
79
|
+
[LAUNCHCTL, 'bootstrap', 'system', `${DAEMONS}/sub/com.example.a.plist`],
|
|
80
|
+
],
|
|
81
|
+
['bootstrap into a gui domain', [LAUNCHCTL, 'bootstrap', 'gui/501', PLIST]],
|
|
82
|
+
['a reserved label', [LAUNCHCTL, 'bootout', 'system/com.apple.xpc.foo']],
|
|
83
|
+
['a label that is a path', [LAUNCHCTL, 'bootout', 'system/../x']],
|
|
84
|
+
[
|
|
85
|
+
'kickstart -s (suspended, for a debugger)',
|
|
86
|
+
[LAUNCHCTL, 'kickstart', '-s', 'system/com.example.a'],
|
|
87
|
+
],
|
|
88
|
+
['a repeated flag', [LAUNCHCTL, 'kickstart', '-k', '-k', 'system/com.example.a']],
|
|
89
|
+
['kickstart of the bare domain', [LAUNCHCTL, 'kickstart', '-k', 'system']],
|
|
90
|
+
['kickstart of a reserved label', [LAUNCHCTL, 'kickstart', 'system/com.apple.xpc.foo']],
|
|
91
|
+
['enable', [LAUNCHCTL, 'enable', 'system/com.example.a']],
|
|
92
|
+
['print, which never needs root', [LAUNCHCTL, 'print', 'system/com.example.a']],
|
|
93
|
+
])('refuses %s', (_name, argv) => {
|
|
94
|
+
expect(allowed(argv)).toBeString();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('without /Library/LaunchDaemons declared, bootstrap and bootout are refused; kickstart is not', () => {
|
|
99
|
+
const narrow = { prefixes: ['/opt/example'] };
|
|
100
|
+
// ⚠️ bootout first, then a refused rm, would leave a plist launchd loads again at boot.
|
|
101
|
+
expect(privilegedProblem([LAUNCHCTL, 'bootout', 'system/com.example.a'], narrow)).toContain(
|
|
102
|
+
'/Library/LaunchDaemons',
|
|
103
|
+
);
|
|
104
|
+
expect(privilegedProblem([LAUNCHCTL, 'bootstrap', 'system', PLIST], narrow)).toContain(
|
|
105
|
+
'/Library/LaunchDaemons',
|
|
106
|
+
);
|
|
107
|
+
expect(
|
|
108
|
+
privilegedProblem([LAUNCHCTL, 'kickstart', 'system/com.example.a'], narrow),
|
|
109
|
+
).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('install and rm shapes', () => {
|
|
113
|
+
const staged = context.staged;
|
|
114
|
+
test.each([
|
|
115
|
+
[[INSTALL, '-S', '-m', '0644', staged, '/opt/example/a.conf']],
|
|
116
|
+
[[INSTALL, '-S', '-m', '4755', '-o', '0', staged, '/opt/example/a']],
|
|
117
|
+
[[INSTALL, '-S', '-m', '0644', '-g', '80', staged, '/opt/example/a']],
|
|
118
|
+
[[INSTALL, '-S', '-m', '0644', '-o', '0', '-g', '0', staged, PLIST]],
|
|
119
|
+
[[RM, '-f', '--', '/opt/example/a.conf']],
|
|
120
|
+
])('allows %j', (argv) => {
|
|
121
|
+
expect(allowed(argv)).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test.each([
|
|
125
|
+
// ⛔ Copying any other source would publish a root-only file.
|
|
126
|
+
[
|
|
127
|
+
'a source it did not stage',
|
|
128
|
+
[INSTALL, '-S', '-m', '0644', '/etc/master.passwd', '/opt/example/a'],
|
|
129
|
+
],
|
|
130
|
+
['a destination outside every prefix', [INSTALL, '-S', '-m', '0644', staged, '/etc/sudoers']],
|
|
131
|
+
['a destination that walks out', [INSTALL, '-S', '-m', '0644', staged, '/opt/example/../x']],
|
|
132
|
+
['no -S', [INSTALL, '-m', '0644', staged, '/opt/example/a']],
|
|
133
|
+
['a three-digit mode', [INSTALL, '-S', '-m', '644', staged, '/opt/example/a']],
|
|
134
|
+
['a symbolic mode', [INSTALL, '-S', '-m', 'u+s', staged, '/opt/example/a']],
|
|
135
|
+
['an owner by name', [INSTALL, '-S', '-m', '0644', '-o', 'root', staged, '/opt/example/a']],
|
|
136
|
+
['-g before -o', [INSTALL, '-S', '-m', '0644', '-g', '0', '-o', '0', staged, '/opt/example/a']],
|
|
137
|
+
['-d (make directories)', [INSTALL, '-S', '-m', '0644', '-d', staged, '/opt/example/a']],
|
|
138
|
+
['two destinations', [INSTALL, '-S', '-m', '0644', staged, '/opt/example/a', '/opt/example/b']],
|
|
139
|
+
['rm -r', [RM, '-rf', '--', '/opt/example/a']],
|
|
140
|
+
['rm without --', [RM, '-f', '/opt/example/a']],
|
|
141
|
+
['rm of two paths', [RM, '-f', '--', '/opt/example/a', '/opt/example/b']],
|
|
142
|
+
['rm of a prefix itself', [RM, '-f', '--', '/opt/example']],
|
|
143
|
+
['any other program', ['/bin/sh', '-c', 'true']],
|
|
144
|
+
])('refuses %s', (_name, argv) => {
|
|
145
|
+
expect(allowed(argv)).toBeString();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('without a staged file, no install is allowed at all', () => {
|
|
149
|
+
const argv = [INSTALL, '-S', '-m', '0644', 'undefined', '/opt/example/a'];
|
|
150
|
+
expect(privilegedProblem(argv, { prefixes: context.prefixes })).toBeString();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('octalMode is four digits, and refuses what chmod cannot mean', () => {
|
|
154
|
+
expect(octalMode(0o644)).toBe('0644');
|
|
155
|
+
expect(octalMode(0o4755)).toBe('4755');
|
|
156
|
+
expect(octalMode(0o10000)).toBeUndefined();
|
|
157
|
+
expect(octalMode(-1)).toBeUndefined();
|
|
158
|
+
expect(octalMode(1.5)).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('routeExec', () => {
|
|
163
|
+
const route = (argv: string[]) => routeExec(argv, 501);
|
|
164
|
+
test('system-domain writes go to root', () => {
|
|
165
|
+
expect(route([LAUNCHCTL, 'bootstrap', 'system', PLIST])).toEqual({ as: 'root' });
|
|
166
|
+
expect(route([LAUNCHCTL, 'bootout', 'system/com.example.a'])).toEqual({ as: 'root' });
|
|
167
|
+
expect(route([LAUNCHCTL, 'kickstart', '-k', 'system/com.example.a'])).toEqual({ as: 'root' });
|
|
168
|
+
// A malformed system write still routes to root, where the allowlist refuses it.
|
|
169
|
+
expect(route([LAUNCHCTL, 'bootout', 'system'])).toEqual({ as: 'root' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('reads, lookups and the operator’s own gui domain stay unelevated', () => {
|
|
173
|
+
expect(route([LAUNCHCTL, 'print', 'system/com.example.a'])).toEqual({ as: 'operator' });
|
|
174
|
+
expect(route([LAUNCHCTL, 'print-disabled', 'system'])).toEqual({ as: 'operator' });
|
|
175
|
+
expect(route([LAUNCHCTL, 'bootstrap', 'gui/501', '/Users/x/a.plist'])).toEqual({
|
|
176
|
+
as: 'operator',
|
|
177
|
+
});
|
|
178
|
+
expect(route([LAUNCHCTL, 'bootout', 'gui/501/com.example.a'])).toEqual({ as: 'operator' });
|
|
179
|
+
expect(route(['/usr/bin/dscacheutil', '-q', 'user'])).toEqual({ as: 'operator' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('another user’s domain is refused, not passed through to fail on EPERM', () => {
|
|
183
|
+
expect(route([LAUNCHCTL, 'bootout', 'gui/502/com.example.a'])).toHaveProperty('refuse');
|
|
184
|
+
expect(route([LAUNCHCTL, 'kickstart', 'user/502/com.example.a'])).toHaveProperty('refuse');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a sudo argv is never passed through', () => {
|
|
188
|
+
expect(route(['/usr/bin/sudo', '-n', '/bin/rm', '-rf', '/'])).toHaveProperty('refuse');
|
|
189
|
+
expect(route(['sudo', 'true'])).toHaveProperty('refuse');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything sudo-runner.ts may run as root, as exact argv shapes, plus the rule for which calls
|
|
3
|
+
* need root at all. Pure: no I/O, so every shape is tested without sudo (sudo-allowlist.test.ts).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ AN ALLOWLIST OF SHAPES, NOT OF PROGRAMS. Allowing "launchctl" would allow `bootout system` with
|
|
6
|
+
* no service, which removes the whole system domain, and `bootstrap system <directory>`, which
|
|
7
|
+
* loads every plist in that directory (launchctl(1), read on macOS 27.2 on 2026-09-21). Each shape
|
|
8
|
+
* below fixes the subcommand, the domain, the flags and the number of operands, so neither of
|
|
9
|
+
* those can be written.
|
|
10
|
+
* ⛔ PATHS ARE CHECKED LEXICALLY HERE: absolute, normalised (the same rule a HostFile path obeys,
|
|
11
|
+
* host-file-form.ts) and STRICTLY under a prefix the stack declared. sudo-guard.ts then checks
|
|
12
|
+
* the host with lstat: the prefix a root-only directory, no symlinks, and only regular files.
|
|
13
|
+
* ⛔ `install` COPIES ONLY THE FILE THIS RUNNER STAGED. Any other source would let the runner copy
|
|
14
|
+
* a file only root can read (/etc/master.passwd, say) to a path the deploying user can read.
|
|
15
|
+
*/
|
|
16
|
+
import { pathProblems } from './host-file-form.ts';
|
|
17
|
+
import { plistPathFor } from './job-form.ts';
|
|
18
|
+
import { LABEL, RESERVED_LABEL_PREFIXES } from './job-validate.ts';
|
|
19
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A privileged call that was refused before it ran: by the allowlist, by a host check
|
|
23
|
+
* (sudo-guard.ts), or by sudo itself. ★ Here, beside the allowlist, so the guard and the runner
|
|
24
|
+
* throw the same class and a caller can tell "nothing ran as root" from "it ran and failed".
|
|
25
|
+
*/
|
|
26
|
+
export class SudoRefusedError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'SudoRefusedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** ★ Absolute program paths, so neither sudo nor the command is looked up on the caller's PATH. */
|
|
34
|
+
export const SUDO = '/usr/bin/sudo';
|
|
35
|
+
export const INSTALL = '/usr/bin/install';
|
|
36
|
+
export const RM = '/bin/rm';
|
|
37
|
+
|
|
38
|
+
/** The launchctl subcommands that change a domain. Everything else launchctl does is a read. */
|
|
39
|
+
const WRITES: ReadonlySet<string> = new Set(['bootstrap', 'bootout', 'kickstart']);
|
|
40
|
+
|
|
41
|
+
/** A mode as `install -m` takes it: four octal digits, so `0644` and `4755` are both exact. */
|
|
42
|
+
export const octalMode = (mode: number): string | undefined =>
|
|
43
|
+
Number.isSafeInteger(mode) && mode >= 0 && mode <= 0o7777
|
|
44
|
+
? mode.toString(8).padStart(4, '0')
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
/** Validate the stack's prefixes once, when the runner is built. */
|
|
48
|
+
export const checkPrefixes = (prefixes: readonly string[]): readonly string[] => {
|
|
49
|
+
if (prefixes.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'sudoRunner: no prefixes. Name the directories root may write, e.g. /Library/LaunchDaemons.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
for (const prefix of prefixes) {
|
|
55
|
+
const found = prefix === '/' ? ['"/" would allow every path'] : pathProblems(prefix);
|
|
56
|
+
if (found.length > 0) {
|
|
57
|
+
throw new Error(`sudoRunner: prefix ${JSON.stringify(prefix)}: ${found.join('; ')}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...prefixes];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The declared prefix `path` is strictly under, or `undefined`.
|
|
65
|
+
* ⚠️ `${prefix}/`, never a bare startsWith: `/opt/example` must not admit `/opt/example-other`.
|
|
66
|
+
*/
|
|
67
|
+
export const prefixOf = (path: string, prefixes: readonly string[]): string | undefined =>
|
|
68
|
+
pathProblems(path).length > 0
|
|
69
|
+
? undefined
|
|
70
|
+
: prefixes.find((prefix) => path.startsWith(`${prefix}/`));
|
|
71
|
+
|
|
72
|
+
const labelOk = (label: string): boolean =>
|
|
73
|
+
LABEL.test(label) && !RESERVED_LABEL_PREFIXES.some((prefix) => label.startsWith(prefix));
|
|
74
|
+
|
|
75
|
+
/** The label of `system/<label>`, when it is one LaunchdJob itself would accept. */
|
|
76
|
+
const systemLabel = (target: string | undefined): string | undefined => {
|
|
77
|
+
const label = target?.startsWith('system/') === true ? target.slice('system/'.length) : undefined;
|
|
78
|
+
return label !== undefined && labelOk(label) ? label : undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A label's daemon plist, when that path is under a declared prefix.
|
|
83
|
+
* ★ ONLY `/Library/LaunchDaemons/<label>.plist`, derived by the same plistPathFor LaunchdJob uses:
|
|
84
|
+
* it is the one directory launchd loads daemons from at boot, so a plist anywhere else under a
|
|
85
|
+
* prefix would be a root daemon that silently vanishes at the next restart. Root never loads one.
|
|
86
|
+
*/
|
|
87
|
+
const daemonPlist = (label: string, prefixes: readonly string[]): string | undefined => {
|
|
88
|
+
const path = plistPathFor(label, { kind: 'system' }, undefined);
|
|
89
|
+
return prefixOf(path, prefixes) === undefined ? undefined : path;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const NO_DAEMON_PREFIX =
|
|
93
|
+
'needs /Library/LaunchDaemons among the prefixes, where the job’s plist is written and removed';
|
|
94
|
+
|
|
95
|
+
const launchctlProblem = (
|
|
96
|
+
args: readonly string[],
|
|
97
|
+
prefixes: readonly string[],
|
|
98
|
+
): string | undefined => {
|
|
99
|
+
const [sub, ...rest] = args;
|
|
100
|
+
if (sub === 'bootstrap') {
|
|
101
|
+
const given = rest[1] ?? '';
|
|
102
|
+
const name = given.slice(given.lastIndexOf('/') + 1);
|
|
103
|
+
const label = name.endsWith('.plist') ? name.slice(0, -'.plist'.length) : '';
|
|
104
|
+
const shaped = rest.length === 2 && rest[0] === 'system' && labelOk(label);
|
|
105
|
+
if (!shaped) return 'bootstrap takes exactly `system /Library/LaunchDaemons/<label>.plist`';
|
|
106
|
+
const plist = daemonPlist(label, prefixes);
|
|
107
|
+
if (plist === undefined) return `bootstrap ${NO_DAEMON_PREFIX}`;
|
|
108
|
+
return given === plist ? undefined : `bootstrap loads only ${plist}, never another path`;
|
|
109
|
+
}
|
|
110
|
+
if (sub === 'bootout') {
|
|
111
|
+
const label = rest.length === 1 ? systemLabel(rest[0]) : undefined;
|
|
112
|
+
if (label === undefined) return 'bootout takes exactly `system/<label>`, never a bare domain';
|
|
113
|
+
/**
|
|
114
|
+
* ⚠️ WITHOUT THE PREFIX, A DELETE STOPS HALFWAY. deleteJob boots the job out, then removes its
|
|
115
|
+
* plist; with /Library/LaunchDaemons undeclared the removal is refused AFTER the bootout, the
|
|
116
|
+
* plist stays, and launchd loads the job again at the next boot. Refused here, nothing moves.
|
|
117
|
+
*/
|
|
118
|
+
return daemonPlist(label, prefixes) === undefined ? `bootout ${NO_DAEMON_PREFIX}` : undefined;
|
|
119
|
+
}
|
|
120
|
+
if (sub === 'kickstart') {
|
|
121
|
+
const flags = rest.slice(0, -1);
|
|
122
|
+
const flagsOk =
|
|
123
|
+
flags.every((flag) => flag === '-k' || flag === '-p') && new Set(flags).size === flags.length;
|
|
124
|
+
return flagsOk && systemLabel(rest[rest.length - 1]) !== undefined
|
|
125
|
+
? undefined
|
|
126
|
+
: 'kickstart takes only -k / -p, then `system/<label>`';
|
|
127
|
+
}
|
|
128
|
+
return `launchctl ${String(sub)} is never run as root`;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const ID = /^\d+$/;
|
|
132
|
+
|
|
133
|
+
/** `install`'s optional owner flags: none, `-o N`, `-g N`, or `-o N -g N`. Numeric ids only. */
|
|
134
|
+
const idsOk = (ids: readonly string[]): boolean => {
|
|
135
|
+
const [flag, id, second, secondId] = ids;
|
|
136
|
+
if (ids.length === 0) return true;
|
|
137
|
+
if (ids.length === 2) return (flag === '-o' || flag === '-g') && ID.test(id ?? '');
|
|
138
|
+
return (
|
|
139
|
+
ids.length === 4 &&
|
|
140
|
+
flag === '-o' &&
|
|
141
|
+
ID.test(id ?? '') &&
|
|
142
|
+
second === '-g' &&
|
|
143
|
+
ID.test(secondId ?? '')
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The one source `install` may copy and the prefixes, for the call being checked. */
|
|
148
|
+
export type AllowContext = { readonly prefixes: readonly string[]; readonly staged?: string };
|
|
149
|
+
|
|
150
|
+
/** `undefined` when `argv` is exactly an allowed privileged shape, else the reason it is not. */
|
|
151
|
+
export const privilegedProblem = (
|
|
152
|
+
argv: readonly string[],
|
|
153
|
+
context: AllowContext,
|
|
154
|
+
): string | undefined => {
|
|
155
|
+
const [program, ...args] = argv;
|
|
156
|
+
const under = (path: string | undefined) =>
|
|
157
|
+
path !== undefined && prefixOf(path, context.prefixes) !== undefined;
|
|
158
|
+
if (program === LAUNCHCTL) return launchctlProblem(args, context.prefixes);
|
|
159
|
+
if (program === INSTALL) {
|
|
160
|
+
// -S -m MODE [-o UID] [-g GID] STAGED DEST: nothing else, in this order.
|
|
161
|
+
const [safe, m, mode, ...rest] = args;
|
|
162
|
+
if (safe !== '-S' || m !== '-m' || mode === undefined || !/^[0-7]{4}$/.test(mode)) {
|
|
163
|
+
return 'install takes exactly `-S -m <4 octal digits> [-o <uid>] [-g <gid>] <staged> <dest>`';
|
|
164
|
+
}
|
|
165
|
+
if (rest.length < 2 || !idsOk(rest.slice(0, -2))) return 'install takes only -o <uid> -g <gid>';
|
|
166
|
+
const [source, dest] = rest.slice(-2);
|
|
167
|
+
if (source === undefined || source !== context.staged) {
|
|
168
|
+
return 'install copies only the file this runner staged';
|
|
169
|
+
}
|
|
170
|
+
return under(dest) ? undefined : 'install destination is not under a declared prefix';
|
|
171
|
+
}
|
|
172
|
+
if (program === RM) {
|
|
173
|
+
const [force, end, path, ...extra] = args;
|
|
174
|
+
return force === '-f' && end === '--' && extra.length === 0 && under(path)
|
|
175
|
+
? undefined
|
|
176
|
+
: 'rm takes exactly `-f -- <path under a declared prefix>`';
|
|
177
|
+
}
|
|
178
|
+
return `${String(program)} is not on the sudo allowlist`;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Where one exec goes: run as the deploying user, run as root through the allowlist, or refused. */
|
|
182
|
+
export type Route =
|
|
183
|
+
| { readonly as: 'operator' }
|
|
184
|
+
| { readonly as: 'root' }
|
|
185
|
+
| { readonly refuse: string };
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* ★ WHY THIS REFUSES AS WELL AS ROUTES. sudoRunner reports `privileged: true`, which switches off
|
|
189
|
+
* the providers' own "another user's gui domain needs root" refusal (job-preflight.ts). This
|
|
190
|
+
* runner elevates only the system domain, so it gives that refusal back itself. (A write into
|
|
191
|
+
* another user's LaunchAgents is refused by the writer first: outside every prefix, a foreign
|
|
192
|
+
* owner needs root. This covers the delete, which reaches launchctl before any file.)
|
|
193
|
+
* ⚠️ Reads (`print`, `print-disabled`) always run as the deploying user: launchctl(1) says anyone
|
|
194
|
+
* may read or query the system domain, and launchctl.ts measured it on 2026-09-21.
|
|
195
|
+
*/
|
|
196
|
+
export const routeExec = (argv: readonly string[], operatorUid: number): Route => {
|
|
197
|
+
const [program, sub] = argv;
|
|
198
|
+
if (program === 'sudo' || program?.endsWith('/sudo') === true) {
|
|
199
|
+
return {
|
|
200
|
+
refuse: 'sudoRunner never passes a sudo argv through; it elevates only its allowlist',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (program !== LAUNCHCTL || sub === undefined || !WRITES.has(sub)) return { as: 'operator' };
|
|
204
|
+
const target = sub === 'kickstart' ? argv[argv.length - 1] : argv[2];
|
|
205
|
+
if (target === 'system' || target?.startsWith('system/') === true) return { as: 'root' };
|
|
206
|
+
const owner = /^(?:gui|user)\/(\d+)(?:\/|$)/.exec(target ?? '')?.[1];
|
|
207
|
+
if (owner === undefined || Number(owner) === operatorUid) return { as: 'operator' };
|
|
208
|
+
return {
|
|
209
|
+
refuse:
|
|
210
|
+
`launchctl ${sub} ${String(target)}: that domain belongs to uid ${owner}, and sudoRunner ` +
|
|
211
|
+
'elevates only the system domain. Deploy as that user, or as root.',
|
|
212
|
+
};
|
|
213
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner's red-team refusals (2026-09-21), over the fake host (fake-sudo.ts): a directory ABOVE
|
|
3
|
+
* the prefix that someone other than root may change, and an ACL anywhere from / down that grants a
|
|
4
|
+
* write right. Either lets that someone swap the path between the guard and `sudo -n`. Each is
|
|
5
|
+
* refused before anything is staged or run as root, and at plan time through `checkWrite`.
|
|
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 { HostRunner } from './runner.ts';
|
|
11
|
+
import { aclGrants, assertNoAclGrants } from './sudo-acl.ts';
|
|
12
|
+
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
13
|
+
import { aboveDirs } 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 entry = (kind: 'directory' | 'symlink', mode: number, uid = 0) => ({
|
|
19
|
+
bytes: bytes(''),
|
|
20
|
+
gid: 0,
|
|
21
|
+
kind,
|
|
22
|
+
mode,
|
|
23
|
+
uid,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** As `ls -lden` printed them on macOS 27.2 (sudo-acl.ts): a UUID qualifier, one entry a line. */
|
|
27
|
+
const UUID = 'ABCDEFAB-CDEF-ABCD-EFAB-CDEF00000014';
|
|
28
|
+
|
|
29
|
+
test('aboveDirs is / down to the parent of the prefix', () => {
|
|
30
|
+
expect(aboveDirs('/Library/LaunchDaemons')).toEqual(['/', '/Library']);
|
|
31
|
+
expect(aboveDirs('/opt/example')).toEqual(['/', '/opt']);
|
|
32
|
+
expect(aboveDirs('/opt')).toEqual(['/']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('aclGrants', () => {
|
|
36
|
+
test('an allow of a write right counts; deny, and read-only rights, do not', () => {
|
|
37
|
+
const listing = [
|
|
38
|
+
`drwxr-xr-x@ 3 0 0 96 Sep 21 19:36 /opt/example`,
|
|
39
|
+
` 0: ${UUID} allow add_file,add_subdirectory,delete_child`,
|
|
40
|
+
` 1: ${UUID} inherited allow write,file_inherit`,
|
|
41
|
+
` 2: ${UUID} deny delete`,
|
|
42
|
+
` 3: ${UUID} allow list,search,readattr`,
|
|
43
|
+
].join('\n');
|
|
44
|
+
expect(aclGrants(listing)).toEqual([
|
|
45
|
+
`0: ${UUID} allow add_file,add_subdirectory,delete_child`,
|
|
46
|
+
`1: ${UUID} inherited allow write,file_inherit`,
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('fails closed when the ACLs cannot be read', async () => {
|
|
51
|
+
const base = { exec: async () => ({ exitCode: 1, stderr: 'ls: x', stdout: '' }) };
|
|
52
|
+
const read = assertNoAclGrants(base as unknown as HostRunner, CONF, ['/']);
|
|
53
|
+
await expect(read).rejects.toThrow('could not read the ACLs');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const refusedBeforeSudo = async (host: Host, says: string | RegExp) => {
|
|
58
|
+
const write = host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
59
|
+
await expect(write).rejects.toBeInstanceOf(SudoRefusedError);
|
|
60
|
+
await expect(write).rejects.toThrow(says);
|
|
61
|
+
expect(host.state.stagedCount).toBe(0);
|
|
62
|
+
expect(host.sudoCalls).toEqual([]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe('every directory above the prefix', () => {
|
|
66
|
+
test.each([
|
|
67
|
+
['owned by the operator', (h: Host) => h.fake.dirs.set('/opt', OPERATOR), 'uid 501'],
|
|
68
|
+
['group-writable', (h: Host) => h.fake.files.set('/opt', entry('directory', 0o775)), '0775'],
|
|
69
|
+
[
|
|
70
|
+
'a symlink the operator owns',
|
|
71
|
+
(h: Host) => h.fake.files.set('/opt', entry('symlink', 0o755, OPERATOR)),
|
|
72
|
+
'symlink',
|
|
73
|
+
],
|
|
74
|
+
] as const)('%s is refused before staging or sudo', async (_n, setup, says) => {
|
|
75
|
+
const host = fakeSudoHost();
|
|
76
|
+
setup(host);
|
|
77
|
+
await refusedBeforeSudo(host, says);
|
|
78
|
+
await expect(host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 })).rejects.toThrow(
|
|
79
|
+
/\/opt, above the declared prefix \/opt\/example/,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a root-owned symlink (/etc -> private/etc) is root's own, and followed", async () => {
|
|
84
|
+
const host = fakeSudoHost();
|
|
85
|
+
host.fake.files.set('/opt', entry('symlink', 0o755));
|
|
86
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
87
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ mode: 0o644, uid: 0 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a HostFile plan that would write under it fails, still without sudo', async () => {
|
|
91
|
+
const host = fakeSudoHost();
|
|
92
|
+
const output = await reconcileFile(host.runner, { content: 'x', path: CONF });
|
|
93
|
+
host.fake.dirs.set('/opt', OPERATOR);
|
|
94
|
+
const before = host.sudoCalls.length;
|
|
95
|
+
await expect(diffFile(host.runner, { content: 'y', path: CONF }, output)).rejects.toThrow(
|
|
96
|
+
'uid 501',
|
|
97
|
+
);
|
|
98
|
+
expect(host.sudoCalls).toHaveLength(before);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('an ACL from / down to the file', () => {
|
|
103
|
+
test.each(['/', '/opt', '/opt/example', '/opt/example/app'])(
|
|
104
|
+
'that grants a write right on %s is refused before staging or sudo',
|
|
105
|
+
async (dir) => {
|
|
106
|
+
const host = fakeSudoHost();
|
|
107
|
+
host.fake.acls.set(dir, [` 0: ${UUID} allow add_file,delete_child`]);
|
|
108
|
+
await refusedBeforeSudo(host, 'carries an ACL');
|
|
109
|
+
host.fake.files.set(CONF, { bytes: bytes('x'), gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
110
|
+
await expect(host.runner.removeFile(CONF)).rejects.toThrow('carries an ACL');
|
|
111
|
+
expect(host.sudoCalls).toEqual([]);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
test('a deny-only ACL (as on a home folder) is no refusal', async () => {
|
|
116
|
+
const host = fakeSudoHost();
|
|
117
|
+
host.fake.acls.set('/opt/example/app', [` 0: ${UUID} deny delete`]);
|
|
118
|
+
await host.runner.writeFileAtomic(CONF, bytes('x'), { mode: 0o644 });
|
|
119
|
+
expect(host.fake.files.get(CONF)).toMatchObject({ uid: 0 });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard's pure parts, the `id -G` parse, and the real staging code on a real temp directory.
|
|
3
|
+
* Nothing here runs sudo or writes outside `os.tmpdir()`.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { stat } from 'node:fs/promises';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { fakeRunner } from './fake-runner.ts';
|
|
9
|
+
import type { ExecResult } from './runner.ts';
|
|
10
|
+
import { betweenDirs, canRead, operatorGroups } from './sudo-guard.ts';
|
|
11
|
+
import { stageFile } from './sudo-stage.ts';
|
|
12
|
+
|
|
13
|
+
describe('canRead', () => {
|
|
14
|
+
const file = (uid: number, gid: number, mode: number) => ({ gid, mode, uid });
|
|
15
|
+
test.each([
|
|
16
|
+
['root reads anything', file(0, 0, 0o000), 0, [], true],
|
|
17
|
+
['the owner gets the owner bits only', file(501, 20, 0o044), 501, [20], false],
|
|
18
|
+
['a member gets the group bits', file(0, 80, 0o640), 501, [80], true],
|
|
19
|
+
// ⚠️ Exactly one class applies: a member of the group does not fall through to "other".
|
|
20
|
+
['a member is hidden by 0604', file(0, 80, 0o604), 501, [80], false],
|
|
21
|
+
['a non-member gets the other bits', file(0, 80, 0o604), 501, [20], true],
|
|
22
|
+
['0640 hides it from a non-member', file(0, 0, 0o640), 501, [20, 80], false],
|
|
23
|
+
['0600 root-only hides it', file(0, 0, 0o600), 501, [0], false],
|
|
24
|
+
['0644 is readable whatever the groups', file(0, 0, 0o644), 501, undefined, true],
|
|
25
|
+
['0600 is unreadable whatever the groups', file(0, 0, 0o600), 501, undefined, false],
|
|
26
|
+
[
|
|
27
|
+
'only membership could decide, and it is unknown',
|
|
28
|
+
file(0, 80, 0o640),
|
|
29
|
+
501,
|
|
30
|
+
undefined,
|
|
31
|
+
undefined,
|
|
32
|
+
],
|
|
33
|
+
] as const)('%s', (_name, f, uid, groups, expected) => {
|
|
34
|
+
expect(canRead(f, uid, groups)).toBe(expected);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('betweenDirs lists every directory strictly between the prefix and the file', () => {
|
|
39
|
+
expect(betweenDirs('/opt/example', '/opt/example/a.conf')).toEqual([]);
|
|
40
|
+
expect(betweenDirs('/opt/example', '/opt/example/app/etc/a.conf')).toEqual([
|
|
41
|
+
'/opt/example/app',
|
|
42
|
+
'/opt/example/app/etc',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('operatorGroups', () => {
|
|
47
|
+
const withId = (result: ExecResult) => {
|
|
48
|
+
const { runner } = fakeRunner();
|
|
49
|
+
const calls: string[][] = [];
|
|
50
|
+
return {
|
|
51
|
+
calls,
|
|
52
|
+
runner: {
|
|
53
|
+
...runner,
|
|
54
|
+
exec: async (argv: readonly string[]) => (calls.push([...argv]), result),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
test('parses `id -G`, run as the operator with an absolute path', async () => {
|
|
60
|
+
const host = withId({ exitCode: 0, stderr: '', stdout: '20 12 61 80 400 702\n' });
|
|
61
|
+
expect(await operatorGroups(host.runner)).toEqual([20, 12, 61, 80, 400, 702]);
|
|
62
|
+
expect(host.calls).toEqual([['/usr/bin/id', '-G']]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test.each([
|
|
66
|
+
['a failed lookup, whatever it printed', { exitCode: 1, stderr: 'id: failed', stdout: '20\n' }],
|
|
67
|
+
['empty output', { exitCode: 0, stderr: '', stdout: '\n' }],
|
|
68
|
+
['anything but numbers', { exitCode: 0, stderr: '', stdout: '20 staff\n' }],
|
|
69
|
+
])('%s is unknown, never an empty list', async (_name, result) => {
|
|
70
|
+
expect(await operatorGroups(withId(result).runner)).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('stageFile (real temp directory)', () => {
|
|
75
|
+
test('0600 file in a 0700 directory, exact bytes, gone after dispose', async () => {
|
|
76
|
+
const staged = await stageFile(new TextEncoder().encode('listen: 127.0.0.1:9100\n'));
|
|
77
|
+
try {
|
|
78
|
+
expect(await Bun.file(staged.path).text()).toBe('listen: 127.0.0.1:9100\n');
|
|
79
|
+
expect((await stat(staged.path)).mode & 0o777).toBe(0o600);
|
|
80
|
+
expect((await stat(dirname(staged.path))).mode & 0o777).toBe(0o700);
|
|
81
|
+
} finally {
|
|
82
|
+
await staged.dispose();
|
|
83
|
+
}
|
|
84
|
+
// ⚠️ stat, not Bun.file().exists(): measured 2026-09-21 on bun 1.4.0, exists() is false for a
|
|
85
|
+
// directory that DOES exist, so it could never catch a leaked staging directory.
|
|
86
|
+
await expect(stat(dirname(staged.path))).rejects.toThrow('ENOENT');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('two stagings never share a file', async () => {
|
|
90
|
+
const [a, b] = await Promise.all([
|
|
91
|
+
stageFile(new Uint8Array([1])),
|
|
92
|
+
stageFile(new Uint8Array([2])),
|
|
93
|
+
]);
|
|
94
|
+
try {
|
|
95
|
+
expect(a?.path).not.toBe(b?.path);
|
|
96
|
+
} finally {
|
|
97
|
+
await Promise.all([a?.dispose(), b?.dispose()]);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|