@homeflare/alchemy 0.25.1 → 0.27.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/dist/github/declare-repo-baseline.d.ts +11 -0
- package/dist/github/declare-repo-baseline.d.ts.map +1 -0
- package/dist/github/index.d.ts +20 -11
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +715 -4
- package/dist/github/index.js.map +14 -4
- package/dist/github/repo-baseline-data.d.ts +75 -0
- package/dist/github/repo-baseline-data.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +4 -4
- package/dist/github/repo-policy.d.ts.map +1 -1
- package/dist/github/repository-ruleset-constraints.d.ts +105 -0
- package/dist/github/repository-ruleset-constraints.d.ts.map +1 -0
- package/dist/github/repository-ruleset-errors.d.ts +97 -0
- package/dist/github/repository-ruleset-errors.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts +24 -0
- package/dist/github/repository-ruleset-fake-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-fixtures.d.ts +20 -0
- package/dist/github/repository-ruleset-fixtures.d.ts.map +1 -0
- package/dist/github/repository-ruleset-form.d.ts +30 -0
- package/dist/github/repository-ruleset-form.d.ts.map +1 -0
- package/dist/github/repository-ruleset-guards.d.ts +70 -0
- package/dist/github/repository-ruleset-guards.d.ts.map +1 -0
- package/dist/github/repository-ruleset-octokit.d.ts +5 -0
- package/dist/github/repository-ruleset-octokit.d.ts.map +1 -0
- package/dist/github/repository-ruleset-probe.d.ts +87 -0
- package/dist/github/repository-ruleset-probe.d.ts.map +1 -0
- package/dist/github/repository-ruleset-reconcile.d.ts +24 -0
- package/dist/github/repository-ruleset-reconcile.d.ts.map +1 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts +32 -0
- package/dist/github/repository-ruleset-test-helpers.d.ts.map +1 -0
- package/dist/github/repository-ruleset.d.ts +89 -0
- package/dist/github/repository-ruleset.d.ts.map +1 -0
- package/dist/launchd/index.js +51 -47
- package/dist/launchd/index.js.map +5 -4
- package/dist/launchd/sudo-guard-mode.d.ts +62 -0
- package/dist/launchd/sudo-guard-mode.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +1 -38
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/linux/fake-sudo-root.d.ts +26 -0
- package/dist/linux/fake-sudo-root.d.ts.map +1 -0
- package/dist/linux/fake-sudo.d.ts +56 -0
- package/dist/linux/fake-sudo.d.ts.map +1 -0
- package/dist/linux/index.d.ts +2 -0
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +1134 -2
- package/dist/linux/index.js.map +22 -5
- package/dist/linux/sudo-allowlist-chown.d.ts +12 -0
- package/dist/linux/sudo-allowlist-chown.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist-dir.d.ts +6 -0
- package/dist/linux/sudo-allowlist-dir.d.ts.map +1 -0
- package/dist/linux/sudo-allowlist.d.ts +40 -0
- package/dist/linux/sudo-allowlist.d.ts.map +1 -0
- package/dist/linux/sudo-exec.d.ts +31 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -0
- package/dist/linux/sudo-guard.d.ts +36 -0
- package/dist/linux/sudo-guard.d.ts.map +1 -0
- package/dist/linux/sudo-runner.d.ts +37 -0
- package/dist/linux/sudo-runner.d.ts.map +1 -0
- package/dist/linux/sudo-stage.d.ts +22 -0
- package/dist/linux/sudo-stage.d.ts.map +1 -0
- package/dist/linux/sudo-write.d.ts +25 -0
- package/dist/linux/sudo-write.d.ts.map +1 -0
- package/dist/linux/unit-form.d.ts +2 -0
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/litellm/fake-litellm.d.ts.map +1 -1
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -1
- package/docs/launchd-sudo.md +12 -3
- package/docs/linux-host.md +3 -3
- package/docs/linux-sudo.md +187 -0
- package/docs/repo-policy-ruleset-hazards.md +8 -0
- package/docs/repo-policy.md +27 -12
- package/docs/repository-ruleset.md +114 -0
- package/docs/upstream-conformance.md +12 -1
- package/docs/upstream-pr-ruleset-fixes.md +77 -0
- package/package.json +1 -1
- package/src/github/declare-repo-baseline.ts +42 -0
- package/src/github/index.ts +43 -11
- package/src/github/repo-baseline-data.test.ts +76 -0
- package/src/github/repo-baseline-data.ts +86 -0
- package/src/github/repo-policy.ts +17 -17
- package/src/github/repository-ruleset-constraints.ts +136 -0
- package/src/github/repository-ruleset-coverage.test.ts +82 -0
- package/src/github/repository-ruleset-errors.ts +136 -0
- package/src/github/repository-ruleset-fake-octokit.ts +131 -0
- package/src/github/repository-ruleset-fixtures.ts +183 -0
- package/src/github/repository-ruleset-form.ts +212 -0
- package/src/github/repository-ruleset-guards.ts +194 -0
- package/src/github/repository-ruleset-lifecycle.test.ts +172 -0
- package/src/github/repository-ruleset-octokit.ts +140 -0
- package/src/github/repository-ruleset-probe.ts +116 -0
- package/src/github/repository-ruleset-reconcile.ts +176 -0
- package/src/github/repository-ruleset-test-helpers.ts +102 -0
- package/src/github/repository-ruleset-wire.test.ts +66 -0
- package/src/github/repository-ruleset-write-refusals.test.ts +199 -0
- package/src/github/repository-ruleset-write.test.ts +83 -0
- package/src/github/repository-ruleset.ts +219 -0
- package/src/launchd/sudo-guard-mode.ts +111 -0
- package/src/launchd/sudo-guard.ts +3 -79
- package/src/launchd/sudo-modes.test.ts +38 -1
- package/src/linux/fake-sudo-root.ts +157 -0
- package/src/linux/fake-sudo.ts +142 -0
- package/src/linux/index.ts +2 -0
- package/src/linux/sudo-allowlist-chown.ts +71 -0
- package/src/linux/sudo-allowlist-dir.ts +103 -0
- package/src/linux/sudo-allowlist.test.ts +252 -0
- package/src/linux/sudo-allowlist.ts +223 -0
- package/src/linux/sudo-exec.ts +118 -0
- package/src/linux/sudo-guard.test.ts +136 -0
- package/src/linux/sudo-guard.ts +142 -0
- package/src/linux/sudo-lifecycle.test.ts +151 -0
- package/src/linux/sudo-runner.test.ts +206 -0
- package/src/linux/sudo-runner.ts +164 -0
- package/src/linux/sudo-stage.ts +50 -0
- package/src/linux/sudo-write.ts +167 -0
- package/src/linux/unit-form.ts +2 -1
- package/src/litellm/fake-litellm.ts +2 -0
- package/src/proxmox/client.test.ts +17 -3
- package/src/proxmox/fake-pve-lxc.ts +10 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The allowlist, as pure argv checks — no ssh, no fake host. Each refusal here is a shape that
|
|
3
|
+
* would do real damage as root; each acceptance is a shape sshSudoRunner actually produces.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
type AllowContext,
|
|
8
|
+
CHMOD,
|
|
9
|
+
CHOWN,
|
|
10
|
+
INSTALL,
|
|
11
|
+
MKDIR,
|
|
12
|
+
MV,
|
|
13
|
+
RM,
|
|
14
|
+
RMDIR,
|
|
15
|
+
SYSTEMCTL_ABS,
|
|
16
|
+
canonicalize,
|
|
17
|
+
checkPrefixes,
|
|
18
|
+
octalMode,
|
|
19
|
+
prefixOf,
|
|
20
|
+
privilegedProblem,
|
|
21
|
+
routeExec,
|
|
22
|
+
} from './sudo-allowlist.ts';
|
|
23
|
+
|
|
24
|
+
const PREFIXES = ['/etc/systemd/system', '/usr/local/bin'];
|
|
25
|
+
const STAGED = '/tmp/hf-sudo-1/staged';
|
|
26
|
+
const TEMP = '/etc/systemd/system/.thing.service.hf-abcdef012345.tmp';
|
|
27
|
+
const context: AllowContext = { prefixes: PREFIXES, staged: STAGED, temp: TEMP };
|
|
28
|
+
const allowed = (argv: string[]) => privilegedProblem(argv, context);
|
|
29
|
+
|
|
30
|
+
describe('checkPrefixes / prefixOf', () => {
|
|
31
|
+
test('reused from the Mac allowlist unchanged', () => {
|
|
32
|
+
expect(() => checkPrefixes([])).toThrow('no prefixes');
|
|
33
|
+
expect(checkPrefixes(PREFIXES)).toEqual(PREFIXES);
|
|
34
|
+
expect(prefixOf('/etc/systemd/system/x.service', PREFIXES)).toBe('/etc/systemd/system');
|
|
35
|
+
expect(prefixOf('/etc/systemd/system-other/x', PREFIXES)).toBeUndefined();
|
|
36
|
+
expect(octalMode(0o644)).toBe('0644');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('install shapes', () => {
|
|
41
|
+
test.each([
|
|
42
|
+
[[INSTALL, '-m', '0644', '-T', '--', STAGED, TEMP]],
|
|
43
|
+
[[INSTALL, '-m', '0755', '-T', '--', STAGED, TEMP]],
|
|
44
|
+
])('allows %j', (argv) => {
|
|
45
|
+
expect(allowed(argv)).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test.each([
|
|
49
|
+
['a source it did not stage', [INSTALL, '-m', '0644', '-T', '--', '/etc/master.passwd', TEMP]],
|
|
50
|
+
[
|
|
51
|
+
'a target that is not the derived temp',
|
|
52
|
+
[INSTALL, '-m', '0644', '-T', '--', STAGED, '/etc/systemd/system/x.service'],
|
|
53
|
+
],
|
|
54
|
+
['a temp outside every prefix', [INSTALL, '-m', '0644', '-T', '--', STAGED, '/tmp/x.tmp']],
|
|
55
|
+
['no -T', [INSTALL, '-m', '0644', '--', STAGED, TEMP]],
|
|
56
|
+
['a three-digit mode', [INSTALL, '-m', '644', '-T', '--', STAGED, TEMP]],
|
|
57
|
+
['-S (the Mac flag)', [INSTALL, '-S', '-m', '0644', '-T', '--', STAGED, TEMP]],
|
|
58
|
+
['two destinations', [INSTALL, '-m', '0644', '-T', '--', STAGED, TEMP, TEMP]],
|
|
59
|
+
// 🔴 Adversarial review, round 2: install has no `+`-forcing mechanism at all (coreutils'
|
|
60
|
+
// `get_ids()` always tries getpwnam/getgrnam first), so -o/-g are refused outright now —
|
|
61
|
+
// ownership is set afterward by a separate, `+`-forced chown on the same temp (below).
|
|
62
|
+
['-o, now refused outright', [INSTALL, '-m', '0644', '-o', '0', '-T', '--', STAGED, TEMP]],
|
|
63
|
+
['-g, now refused outright', [INSTALL, '-m', '0644', '-g', '0', '-T', '--', STAGED, TEMP]],
|
|
64
|
+
])('refuses %s', (_name, argv) => {
|
|
65
|
+
expect(allowed(argv)).toBeString();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('the file-ownership chown, bound to the derived temp', () => {
|
|
70
|
+
test.each([
|
|
71
|
+
[[CHOWN, '+900', '--', TEMP]],
|
|
72
|
+
[[CHOWN, ':+60', '--', TEMP]],
|
|
73
|
+
[[CHOWN, '+900:+60', '--', TEMP]],
|
|
74
|
+
])('allows %j', (argv) => {
|
|
75
|
+
expect(allowed(argv)).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.each([
|
|
79
|
+
['a bare digit, not +-forced', [CHOWN, '900', '--', TEMP]],
|
|
80
|
+
['a name', [CHOWN, '+root', '--', TEMP]],
|
|
81
|
+
['a path that is not the derived temp', [CHOWN, '+900', '--', '/usr/local/bin/other']],
|
|
82
|
+
['only the group side forced', [CHOWN, '900:+60', '--', TEMP]],
|
|
83
|
+
['only the owner side forced', [CHOWN, '+900:60', '--', TEMP]],
|
|
84
|
+
])('refuses %s', (_name, argv) => {
|
|
85
|
+
expect(allowed(argv)).toBeString();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('mv and rm shapes', () => {
|
|
90
|
+
test.each([
|
|
91
|
+
[[MV, '-f', '-T', '--', TEMP, '/etc/systemd/system/thing.service']],
|
|
92
|
+
[[RM, '-f', '--', TEMP]],
|
|
93
|
+
])('allows %j', (argv) => {
|
|
94
|
+
expect(allowed(argv)).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test.each([
|
|
98
|
+
[
|
|
99
|
+
'mv source not the derived temp',
|
|
100
|
+
[MV, '-f', '-T', '--', '/etc/systemd/system/other.tmp', '/etc/systemd/system/thing.service'],
|
|
101
|
+
],
|
|
102
|
+
['mv to another directory', [MV, '-f', '-T', '--', TEMP, '/usr/local/bin/thing']],
|
|
103
|
+
['mv without -T', [MV, '-f', '--', TEMP, '/etc/systemd/system/thing.service']],
|
|
104
|
+
['rm -r', [RM, '-rf', '--', TEMP]],
|
|
105
|
+
['rm without --', [RM, '-f', TEMP]],
|
|
106
|
+
['rm outside every prefix', [RM, '-f', '--', '/tmp/x']],
|
|
107
|
+
['sudo in argv', ['/usr/bin/sudo', '-n', RM, '-f', '--', TEMP]],
|
|
108
|
+
])('refuses %s', (_name, argv) => {
|
|
109
|
+
expect(allowed(argv)).toBeString();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('directory shapes', () => {
|
|
114
|
+
test.each([
|
|
115
|
+
[[MKDIR, '-m', '750', '--', '/etc/systemd/system/sub']],
|
|
116
|
+
[[MKDIR, '-m', '700', '--', '/etc/systemd/system/sub']],
|
|
117
|
+
[[CHMOD, '750', '--', '/usr/local/bin/sub']],
|
|
118
|
+
// ★ +-forced: this is what canonicalize() actually sends to sudo for a directory chown; the
|
|
119
|
+
// bare `0`/`:0`/`0:0` directory-lifecycle.ts itself sends is covered by the canonicalize
|
|
120
|
+
// test below, and is refused HERE on purpose (privilegedProblem checks the final argv).
|
|
121
|
+
[[CHOWN, '+0', '--', '/usr/local/bin/sub']],
|
|
122
|
+
[[CHOWN, ':+0', '--', '/usr/local/bin/sub']],
|
|
123
|
+
[[CHOWN, '+0:+0', '--', '/usr/local/bin/sub']],
|
|
124
|
+
[[RMDIR, '--', '/etc/systemd/system/sub']],
|
|
125
|
+
])('allows %j', (argv) => {
|
|
126
|
+
expect(allowed(argv)).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test.each([
|
|
130
|
+
['a relative path', [MKDIR, '-m', '750', '--', 'etc/x']],
|
|
131
|
+
['a prefix-sibling', [MKDIR, '-m', '750', '--', '/usr/local/bin-x/sub']],
|
|
132
|
+
['a path that walks out', [MKDIR, '-m', '750', '--', '/etc/systemd/system/../x']],
|
|
133
|
+
['chown by name', [CHOWN, 'root', '--', '/usr/local/bin/sub']],
|
|
134
|
+
[
|
|
135
|
+
'chown, bare digit not +-forced (round 2 of the review)',
|
|
136
|
+
[CHOWN, '0', '--', '/usr/local/bin/sub'],
|
|
137
|
+
],
|
|
138
|
+
['chmod without --', [CHMOD, '750', '/usr/local/bin/sub']],
|
|
139
|
+
['rmdir of two paths', [RMDIR, '--', '/usr/local/bin/sub', '/etc/systemd/system/sub']],
|
|
140
|
+
// 🔴 The three shapes the adversarial review found reachable, 2026-09-23 — see
|
|
141
|
+
// sudo-allowlist-dir.ts's header. Each must now be refused, not merely shaped correctly.
|
|
142
|
+
['mkdir with setuid (4750)', [MKDIR, '-m', '4750', '--', '/etc/systemd/system/sub']],
|
|
143
|
+
['mkdir world-writable (0777)', [MKDIR, '-m', '777', '--', '/etc/systemd/system/sub']],
|
|
144
|
+
[
|
|
145
|
+
'mkdir setgid + group-writable (2775)',
|
|
146
|
+
[MKDIR, '-m', '2775', '--', '/etc/systemd/system/sub'],
|
|
147
|
+
],
|
|
148
|
+
['chmod group-writable (0775)', [CHMOD, '775', '--', '/usr/local/bin/sub']],
|
|
149
|
+
['chown to a non-root uid', [CHOWN, '900', '--', '/usr/local/bin/sub']],
|
|
150
|
+
['chown to a non-root gid only', [CHOWN, ':60', '--', '/usr/local/bin/sub']],
|
|
151
|
+
['chown to a non-root uid and gid', [CHOWN, '900:60', '--', '/usr/local/bin/sub']],
|
|
152
|
+
['chown to a non-root uid, even +-forced', [CHOWN, '+900', '--', '/usr/local/bin/sub']],
|
|
153
|
+
])('refuses %s', (_name, argv) => {
|
|
154
|
+
expect(allowed(argv)).toBeString();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('systemctl shapes', () => {
|
|
159
|
+
test.each([
|
|
160
|
+
[[SYSTEMCTL_ABS, 'daemon-reload']],
|
|
161
|
+
[[SYSTEMCTL_ABS, 'enable', '--', 'thing.service']],
|
|
162
|
+
[[SYSTEMCTL_ABS, 'disable', '--', 'thing.service']],
|
|
163
|
+
[[SYSTEMCTL_ABS, 'start', '--', 'thing.timer']],
|
|
164
|
+
[[SYSTEMCTL_ABS, 'restart', '--', 'thing.service']],
|
|
165
|
+
])('allows %j', (argv) => {
|
|
166
|
+
expect(allowed(argv)).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test.each([
|
|
170
|
+
['--user', [SYSTEMCTL_ABS, '--user', 'enable', '--', 'thing.service']],
|
|
171
|
+
['stop with two units', [SYSTEMCTL_ABS, 'stop', '--', 'a.service', 'b.service']],
|
|
172
|
+
['mask', [SYSTEMCTL_ABS, 'mask', '--', 'thing.service']],
|
|
173
|
+
['daemon-reload with an operand', [SYSTEMCTL_ABS, 'daemon-reload', 'thing.service']],
|
|
174
|
+
['a unit that is not a valid name', [SYSTEMCTL_ABS, 'enable', '--', 'not a unit']],
|
|
175
|
+
])('refuses %s', (_name, argv) => {
|
|
176
|
+
expect(allowed(argv)).toBeString();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('canonicalize', () => {
|
|
181
|
+
test('rewrites a bare directory chown to the +-forced form the allowlist now requires', () => {
|
|
182
|
+
expect(canonicalize(['chown', '0', '--', '/usr/local/bin/sub'])).toEqual([
|
|
183
|
+
CHOWN,
|
|
184
|
+
'+0',
|
|
185
|
+
'--',
|
|
186
|
+
'/usr/local/bin/sub',
|
|
187
|
+
]);
|
|
188
|
+
expect(canonicalize(['chown', ':0', '--', '/usr/local/bin/sub'])).toEqual([
|
|
189
|
+
CHOWN,
|
|
190
|
+
':+0',
|
|
191
|
+
'--',
|
|
192
|
+
'/usr/local/bin/sub',
|
|
193
|
+
]);
|
|
194
|
+
expect(canonicalize(['chown', '900:60', '--', '/usr/local/bin/sub'])).toEqual([
|
|
195
|
+
CHOWN,
|
|
196
|
+
'+900:+60',
|
|
197
|
+
'--',
|
|
198
|
+
'/usr/local/bin/sub',
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('leaves every other program alone', () => {
|
|
203
|
+
expect(canonicalize(['mkdir', '-m', '750', '--', '/usr/local/bin/sub'])).toEqual([
|
|
204
|
+
MKDIR,
|
|
205
|
+
'-m',
|
|
206
|
+
'750',
|
|
207
|
+
'--',
|
|
208
|
+
'/usr/local/bin/sub',
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('routeExec', () => {
|
|
214
|
+
const route = (argv: string[]) => routeExec(argv, PREFIXES);
|
|
215
|
+
|
|
216
|
+
test('systemctl write verbs always attempt root, even malformed', () => {
|
|
217
|
+
expect(route([SYSTEMCTL_ABS, 'daemon-reload'])).toEqual({ as: 'root' });
|
|
218
|
+
expect(route(['systemctl', 'enable', '--', 'thing.service'])).toEqual({ as: 'root' });
|
|
219
|
+
expect(route(['systemctl', 'stop', '--', 'a', 'b'])).toEqual({ as: 'root' });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('systemctl reads stay unelevated', () => {
|
|
223
|
+
expect(route(['systemctl', 'show', '-p', 'LoadState', '--', 'thing.service'])).toEqual({
|
|
224
|
+
as: 'operator',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('directory programs go to root only under a declared prefix', () => {
|
|
229
|
+
expect(route(['mkdir', '-m', '750', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
|
|
230
|
+
expect(route(['mkdir', '-m', '750', '--', '/opt/elsewhere'])).toEqual({ as: 'operator' });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('the absolute program spelling routes the same as the bare one', () => {
|
|
234
|
+
expect(route([MKDIR, '-m', '750', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
|
|
235
|
+
expect(route([CHOWN, '0', '--', '/usr/local/bin/sub'])).toEqual({ as: 'root' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a sudo argv is refused outright, bare or absolute', () => {
|
|
239
|
+
expect(route(['/usr/bin/sudo', '-n', RM, '-f', '--', '/etc/x'])).toHaveProperty('refuse');
|
|
240
|
+
expect(route(['sudo', 'true'])).toHaveProperty('refuse');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('systemctl with a banned flag is refused, not routed to the operator', () => {
|
|
244
|
+
expect(route(['systemctl', 'enable', '--global', '--', 'thing.service'])).toHaveProperty(
|
|
245
|
+
'refuse',
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('everything else runs as the operator', () => {
|
|
250
|
+
expect(route(['getent', 'passwd', '900'])).toEqual({ as: 'operator' });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything sshSudoRunner 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 ssh (sudo-allowlist.test.ts).
|
|
4
|
+
* The Linux twin of `../launchd/sudo-allowlist.ts` — reused directly where the rule is generic
|
|
5
|
+
* (prefixes, octal 4-digit modes, `SudoRefusedError`), rewritten where GNU coreutils and systemd
|
|
6
|
+
* need a different shape than macOS's `install -S` and `launchctl`.
|
|
7
|
+
*
|
|
8
|
+
* ⛔ AN ALLOWLIST OF SHAPES, NOT OF PROGRAMS. `chown` unqualified would let a bug hand any file to
|
|
9
|
+
* any uid; `systemctl` unqualified would let `disable --global` reach every user's units. Each
|
|
10
|
+
* shape below fixes the flags, the operand count and (for install/mv) which paths may appear.
|
|
11
|
+
* ⛔ `install` COPIES ONLY THE FILE THIS RUNNER STAGED, into ONLY the temp path this runner derived
|
|
12
|
+
* for the current write — never argv's own idea of either. Binding both stops a bug (or a future
|
|
13
|
+
* change) from turning this into "install any file the operator can read to any path root can
|
|
14
|
+
* reach".
|
|
15
|
+
* ⛔ GNU `install` WRITES THROUGH ITS DESTINATION (ssh-scripts.ts's header), unlike macOS's, which
|
|
16
|
+
* stages and renames on its own. So there is no `-S`: this allowlist's `install` writes a FRESH
|
|
17
|
+
* temp file (never the live path), and only `mv` — a rename(2), atomic by construction — ever
|
|
18
|
+
* touches the path a provider declared.
|
|
19
|
+
*/
|
|
20
|
+
import { NAME as UNIT_NAME } from './unit-form.ts';
|
|
21
|
+
import {
|
|
22
|
+
INSTALL,
|
|
23
|
+
SUDO,
|
|
24
|
+
SudoRefusedError,
|
|
25
|
+
checkPrefixes,
|
|
26
|
+
octalMode,
|
|
27
|
+
prefixOf,
|
|
28
|
+
} from '../launchd/sudo-allowlist.ts';
|
|
29
|
+
import { chownProblem, forceNumericOwner } from './sudo-allowlist-chown.ts';
|
|
30
|
+
import { CHMOD, CHOWN, MKDIR, RMDIR, dirProgramProblem } from './sudo-allowlist-dir.ts';
|
|
31
|
+
|
|
32
|
+
export { INSTALL, SUDO, SudoRefusedError, checkPrefixes, octalMode, prefixOf };
|
|
33
|
+
export { CHMOD, CHOWN, MKDIR, RMDIR } from './sudo-allowlist-dir.ts';
|
|
34
|
+
|
|
35
|
+
/** ★ Absolute program paths, so neither sudo nor the command is looked up on the operator's PATH. */
|
|
36
|
+
export const MV = '/usr/bin/mv';
|
|
37
|
+
export const RM = '/usr/bin/rm';
|
|
38
|
+
export const SYSTEMCTL_ABS = '/usr/bin/systemctl';
|
|
39
|
+
|
|
40
|
+
/** The bare names `directory-lifecycle.ts` and `systemctl.ts` already send through `exec()`. */
|
|
41
|
+
export const DIRECTORY_PROGRAMS: ReadonlySet<string> = new Set([
|
|
42
|
+
'mkdir',
|
|
43
|
+
'chmod',
|
|
44
|
+
'chown',
|
|
45
|
+
'rmdir',
|
|
46
|
+
]);
|
|
47
|
+
const DIRECTORY_PROGRAMS_ABS: ReadonlySet<string> = new Set([MKDIR, CHMOD, CHOWN, RMDIR]);
|
|
48
|
+
const ABS_OF: Readonly<Record<string, string>> = {
|
|
49
|
+
chmod: CHMOD,
|
|
50
|
+
chown: CHOWN,
|
|
51
|
+
mkdir: MKDIR,
|
|
52
|
+
rmdir: RMDIR,
|
|
53
|
+
systemctl: SYSTEMCTL_ABS,
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The bare argv a provider sends → the absolute argv sudo may run. Unknown programs pass through.
|
|
57
|
+
* ★ `chown`'s owner argument is ALSO rewritten here, to the `+`-forced form `sudo-allowlist-dir.ts`
|
|
58
|
+
* and `sudo-allowlist-chown.ts` now require — `directory-lifecycle.ts` never has to know that
|
|
59
|
+
* syntax exists, and the allowlist checks exactly what will actually reach `sudo`.
|
|
60
|
+
*/
|
|
61
|
+
export const canonicalize = (argv: readonly string[]): readonly string[] => {
|
|
62
|
+
const abs = ABS_OF[argv[0] ?? ''];
|
|
63
|
+
const program = abs ?? argv[0];
|
|
64
|
+
if (program === CHOWN) {
|
|
65
|
+
const [owner, ...rest] = argv.slice(1);
|
|
66
|
+
return [CHOWN, owner === undefined ? '' : forceNumericOwner(owner), ...rest];
|
|
67
|
+
}
|
|
68
|
+
return abs === undefined ? argv : [abs, ...argv.slice(1)];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const dirOf = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
72
|
+
|
|
73
|
+
/** The one source `install` may copy, the one temp `install`/`mv` may touch, and the prefixes. */
|
|
74
|
+
export type AllowContext = {
|
|
75
|
+
readonly prefixes: readonly string[];
|
|
76
|
+
readonly staged?: string;
|
|
77
|
+
readonly temp?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ⛔ NEVER `-o`/`-g` — see sudo-allowlist-chown.ts's header. `install` always writes the fresh temp
|
|
82
|
+
* owned by whoever `sudo` runs it as (root:root), with no name lookup at all; a non-root owner or
|
|
83
|
+
* group is set afterward by a separate, `+`-forced `chown` on that same temp, before `mv`.
|
|
84
|
+
*/
|
|
85
|
+
const installProblem = (args: readonly string[], context: AllowContext): string | undefined => {
|
|
86
|
+
const [m, mode, t, dd, source, dest, ...extra] = args;
|
|
87
|
+
if (
|
|
88
|
+
m !== '-m' ||
|
|
89
|
+
mode === undefined ||
|
|
90
|
+
!/^[0-7]{4}$/.test(mode) ||
|
|
91
|
+
t !== '-T' ||
|
|
92
|
+
dd !== '--' ||
|
|
93
|
+
extra.length !== 0
|
|
94
|
+
) {
|
|
95
|
+
return 'install takes exactly `-m <4 octal digits> -T -- <staged> <temp>`';
|
|
96
|
+
}
|
|
97
|
+
if (source === undefined || source !== context.staged) {
|
|
98
|
+
return 'install copies only the file this runner staged';
|
|
99
|
+
}
|
|
100
|
+
if (dest === undefined || dest !== context.temp) {
|
|
101
|
+
return 'install writes only the derived temp path for this write';
|
|
102
|
+
}
|
|
103
|
+
return prefixOf(dest, context.prefixes) === undefined
|
|
104
|
+
? 'install destination is not under a declared prefix'
|
|
105
|
+
: undefined;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const mvProblem = (args: readonly string[], context: AllowContext): string | undefined => {
|
|
109
|
+
const [f, t, dd, source, dest] = args;
|
|
110
|
+
if (f !== '-f' || t !== '-T' || dd !== '--' || args.length !== 5) {
|
|
111
|
+
return 'mv takes exactly `-f -T -- <temp> <dest>`';
|
|
112
|
+
}
|
|
113
|
+
if (source === undefined || source !== context.temp) {
|
|
114
|
+
return 'mv moves only the temp path this runner just installed to';
|
|
115
|
+
}
|
|
116
|
+
if (dest === undefined || prefixOf(dest, context.prefixes) === undefined) {
|
|
117
|
+
return 'mv destination is not under a declared prefix';
|
|
118
|
+
}
|
|
119
|
+
return dirOf(source) === dirOf(dest)
|
|
120
|
+
? undefined
|
|
121
|
+
: 'mv must stay inside the destination’s directory';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const rmProblem = (args: readonly string[], context: AllowContext): string | undefined => {
|
|
125
|
+
const [force, end, path, ...extra] = args;
|
|
126
|
+
return force === '-f' &&
|
|
127
|
+
end === '--' &&
|
|
128
|
+
extra.length === 0 &&
|
|
129
|
+
path !== undefined &&
|
|
130
|
+
prefixOf(path, context.prefixes) !== undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: 'rm takes exactly `-f -- <path under a declared prefix>`';
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const BANNED_SYSTEMCTL_FLAGS: ReadonlySet<string> = new Set([
|
|
136
|
+
'--user',
|
|
137
|
+
'--global',
|
|
138
|
+
'-H',
|
|
139
|
+
'-M',
|
|
140
|
+
'--root',
|
|
141
|
+
]);
|
|
142
|
+
const UNIT_WRITE_VERBS: ReadonlySet<string> = new Set([
|
|
143
|
+
'enable',
|
|
144
|
+
'disable',
|
|
145
|
+
'start',
|
|
146
|
+
'stop',
|
|
147
|
+
'restart',
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const systemctlProblem = (
|
|
151
|
+
args: readonly string[],
|
|
152
|
+
prefixes: readonly string[],
|
|
153
|
+
): string | undefined => {
|
|
154
|
+
if (args.some((arg) => BANNED_SYSTEMCTL_FLAGS.has(arg))) {
|
|
155
|
+
return 'systemctl --user/--global/-H/-M/--root is never run as root';
|
|
156
|
+
}
|
|
157
|
+
const [verb, ...rest] = args;
|
|
158
|
+
if (verb === 'daemon-reload')
|
|
159
|
+
return rest.length === 0 ? undefined : 'daemon-reload takes no operands';
|
|
160
|
+
if (verb !== undefined && UNIT_WRITE_VERBS.has(verb)) {
|
|
161
|
+
const [dd, unit, ...extra] = rest;
|
|
162
|
+
return dd === '--' && unit !== undefined && UNIT_NAME.test(unit) && extra.length === 0
|
|
163
|
+
? undefined
|
|
164
|
+
: `${verb} takes exactly \`-- <unit>\``;
|
|
165
|
+
}
|
|
166
|
+
return `systemctl ${String(verb)} is never run as root`;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** `undefined` when `argv` is exactly an allowed privileged shape, else the reason it is not. */
|
|
170
|
+
export const privilegedProblem = (
|
|
171
|
+
argv: readonly string[],
|
|
172
|
+
context: AllowContext,
|
|
173
|
+
): string | undefined => {
|
|
174
|
+
const [program, ...args] = argv;
|
|
175
|
+
if (program === INSTALL) return installProblem(args, context);
|
|
176
|
+
if (program === MV) return mvProblem(args, context);
|
|
177
|
+
if (program === RM) return rmProblem(args, context);
|
|
178
|
+
if (program === SYSTEMCTL_ABS) return systemctlProblem(args, context.prefixes);
|
|
179
|
+
if (program === CHOWN) return chownProblem(args, context);
|
|
180
|
+
if (program === MKDIR || program === CHMOD || program === RMDIR) {
|
|
181
|
+
return dirProgramProblem(program, args, context.prefixes);
|
|
182
|
+
}
|
|
183
|
+
return `${String(program)} is not on the sudo allowlist`;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Where one exec goes: run as the operator, run as root through the allowlist, or refused. */
|
|
187
|
+
export type Route =
|
|
188
|
+
| { readonly as: 'operator' }
|
|
189
|
+
| { readonly as: 'root' }
|
|
190
|
+
| { readonly refuse: string };
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ★ ROUTING NEVER TOUCHES THE HOST. mkdir/chmod/chown/rmdir go to root only under a declared
|
|
194
|
+
* prefix — outside one they run as the operator, who may well own that path (H3/rule 7).
|
|
195
|
+
* systemctl's write verbs always ATTEMPT root; the FragmentPath check that can still refuse one
|
|
196
|
+
* needs a host read, so it runs in the runner's `before` hook, not here.
|
|
197
|
+
*/
|
|
198
|
+
export const routeExec = (argv: readonly string[], prefixes: readonly string[]): Route => {
|
|
199
|
+
const [program, ...args] = argv;
|
|
200
|
+
if (program === 'sudo' || program?.endsWith('/sudo') === true) {
|
|
201
|
+
return {
|
|
202
|
+
refuse: 'sshSudoRunner never passes a sudo argv through; it elevates only its allowlist',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (program === 'systemctl' || program === SYSTEMCTL_ABS) {
|
|
206
|
+
if (args.some((arg) => BANNED_SYSTEMCTL_FLAGS.has(arg))) {
|
|
207
|
+
return { refuse: 'sshSudoRunner refuses systemctl --user/--global/-H/-M/--root' };
|
|
208
|
+
}
|
|
209
|
+
const [verb] = args;
|
|
210
|
+
return verb === 'daemon-reload' || (verb !== undefined && UNIT_WRITE_VERBS.has(verb))
|
|
211
|
+
? { as: 'root' }
|
|
212
|
+
: { as: 'operator' };
|
|
213
|
+
}
|
|
214
|
+
// ★ Both spellings route the same way — a caller that happened to pass the absolute program
|
|
215
|
+
// (nothing in this repo does today) must not silently fall through to the operator branch.
|
|
216
|
+
if (DIRECTORY_PROGRAMS.has(program ?? '') || DIRECTORY_PROGRAMS_ABS.has(program ?? '')) {
|
|
217
|
+
const path = args[args.length - 1];
|
|
218
|
+
return path !== undefined && prefixOf(path, prefixes) !== undefined
|
|
219
|
+
? { as: 'root' }
|
|
220
|
+
: { as: 'operator' };
|
|
221
|
+
}
|
|
222
|
+
return { as: 'operator' };
|
|
223
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sshSudoRunner.exec()`'s root branch: the host reads that decide whether a routed-to-root
|
|
3
|
+
* directory or systemctl call may actually elevate. Split out of sudo-runner.ts, which keeps only
|
|
4
|
+
* the wiring. sudo-allowlist.ts's `routeExec` already decided root vs operator vs refuse from the
|
|
5
|
+
* argv alone; this is the part that needs the host.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ THE FragmentPath CHECK IS WHAT MAKES `pveproxy.service` UNREACHABLE. A vendor unit's file
|
|
8
|
+
* lives under `/usr/lib/systemd/system` or `/lib/systemd/system` — never a prefix this runner
|
|
9
|
+
* would declare — so `enable`/`start`/… on it is refused before sudo runs. A unit with NO file
|
|
10
|
+
* at all (FragmentPath empty) is let through for `stop`/`disable` only, because
|
|
11
|
+
* `unit-lifecycle.ts` `deleteUnit` calls exactly those two on a unit whose file this same
|
|
12
|
+
* delete already removed a moment before — refusing that would make a second, idempotent
|
|
13
|
+
* delete pass fail.
|
|
14
|
+
* 🔴 MEASURED (adversarial review, round 2, 2026-09-23): two gaps in this check, both closed here.
|
|
15
|
+
* (1) A MASKED unit (`systemctl mask`, a symlink to `/dev/null`) also reads `FragmentPath=`
|
|
16
|
+
* empty, so `enable`/`start`/`restart` on a masked `pveproxy.service` would have elevated —
|
|
17
|
+
* `systemctl` itself would then refuse the write, but nothing here should even try; masking is
|
|
18
|
+
* someone's deliberate decision (`unit-preflight.ts`'s `assertUsable` already refuses it at the
|
|
19
|
+
* PROVIDER layer, and this is the same rule enforced again at the lowest level, in case a
|
|
20
|
+
* caller reaches `exec()` directly). (2) The empty-FragmentPath exemption applied to EVERY
|
|
21
|
+
* write verb, not just the two `deleteUnit` actually needs — so `enable`/`start`/`restart` on a
|
|
22
|
+
* kernel-generated pseudo-unit with no file at all (`init.scope`, a `session-N.scope`; `.scope`
|
|
23
|
+
* is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
|
|
24
|
+
* in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
|
|
25
|
+
* written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
|
|
26
|
+
*/
|
|
27
|
+
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
28
|
+
import { SYSTEMCTL_ABS, SudoRefusedError, canonicalize, prefixOf } from './sudo-allowlist.ts';
|
|
29
|
+
import { type ChainExpect, assertGuardedChain } from './sudo-guard.ts';
|
|
30
|
+
import type { Elevate } from './sudo-write.ts';
|
|
31
|
+
import { parseShow } from './systemctl.ts';
|
|
32
|
+
|
|
33
|
+
const DIR_EXPECT: Readonly<Record<string, ChainExpect>> = {
|
|
34
|
+
chmod: 'directory',
|
|
35
|
+
chown: 'directory',
|
|
36
|
+
mkdir: 'absent',
|
|
37
|
+
rmdir: 'directory',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The only two verbs `deleteUnit` ever sends against a unit whose file may already be gone. */
|
|
41
|
+
const EMPTY_FRAGMENT_OK: ReadonlySet<string> = new Set(['stop', 'disable']);
|
|
42
|
+
|
|
43
|
+
const unitStatusOf = async (
|
|
44
|
+
base: HostRunner,
|
|
45
|
+
unit: string,
|
|
46
|
+
): Promise<{ readonly fragmentPath: string; readonly loadState: string }> => {
|
|
47
|
+
const result = await base.exec([
|
|
48
|
+
SYSTEMCTL_ABS,
|
|
49
|
+
'show',
|
|
50
|
+
'--no-pager',
|
|
51
|
+
'-p',
|
|
52
|
+
'FragmentPath,LoadState',
|
|
53
|
+
'--',
|
|
54
|
+
unit,
|
|
55
|
+
]);
|
|
56
|
+
if (result.exitCode !== 0) {
|
|
57
|
+
throw new SudoRefusedError(
|
|
58
|
+
`sshSudoRunner ${unit}: could not read FragmentPath/LoadState (systemctl show exit ` +
|
|
59
|
+
`${String(result.exitCode)}). Nothing ran as root.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const fields = parseShow(result.stdout);
|
|
63
|
+
return {
|
|
64
|
+
fragmentPath: fields.get('FragmentPath') ?? '',
|
|
65
|
+
loadState: fields.get('LoadState') ?? '',
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** `argv` has already been routed to root; canonicalise it and run the read this shape needs. */
|
|
70
|
+
export const elevatedExec = (
|
|
71
|
+
base: HostRunner,
|
|
72
|
+
elevate: Elevate,
|
|
73
|
+
prefixes: readonly string[],
|
|
74
|
+
argv: readonly string[],
|
|
75
|
+
): Promise<ExecResult> => {
|
|
76
|
+
const canonical = canonicalize(argv);
|
|
77
|
+
const expect = DIR_EXPECT[argv[0] ?? ''];
|
|
78
|
+
if (expect !== undefined) {
|
|
79
|
+
const path = argv[argv.length - 1] ?? '';
|
|
80
|
+
const prefix = prefixOf(path, prefixes);
|
|
81
|
+
return elevate(canonical, {}, async () => {
|
|
82
|
+
// ★ routeExec already required a prefix to route here; this re-derives it rather than
|
|
83
|
+
// trust the caller, so a future routing bug fails the guard instead of skipping it.
|
|
84
|
+
if (prefix === undefined) {
|
|
85
|
+
throw new SudoRefusedError(
|
|
86
|
+
`sshSudoRunner ${path}: not under a declared prefix. Nothing ran as root.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
await assertGuardedChain(base, prefix, path, expect);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (canonical[0] !== SYSTEMCTL_ABS || canonical[1] === 'daemon-reload') return elevate(canonical);
|
|
93
|
+
const verb = canonical[1] ?? '';
|
|
94
|
+
const unit = canonical[3] ?? '';
|
|
95
|
+
return elevate(canonical, {}, async () => {
|
|
96
|
+
const { fragmentPath, loadState } = await unitStatusOf(base, unit);
|
|
97
|
+
if (loadState === 'masked') {
|
|
98
|
+
throw new SudoRefusedError(
|
|
99
|
+
`sshSudoRunner ${unit}: is masked. Masking is someone's decision, not drift; unmask it ` +
|
|
100
|
+
'deliberately, then redeploy. Nothing ran as root.',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (fragmentPath === '') {
|
|
104
|
+
if (EMPTY_FRAGMENT_OK.has(verb)) return;
|
|
105
|
+
throw new SudoRefusedError(
|
|
106
|
+
`sshSudoRunner ${unit}: has no unit file (FragmentPath empty), and ${verb} is not ` +
|
|
107
|
+
'stop/disable — refusing rather than run it against whatever systemd currently thinks ' +
|
|
108
|
+
`${unit} is (a kernel-generated unit, say). Nothing ran as root.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (prefixOf(fragmentPath, prefixes) === undefined) {
|
|
112
|
+
throw new SudoRefusedError(
|
|
113
|
+
`sshSudoRunner ${unit}: FragmentPath ${fragmentPath} is outside every declared prefix ` +
|
|
114
|
+
`(${prefixes.join(', ')}); refusing to touch a unit this runner does not own. Nothing ran as root.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
};
|