@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
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
import type { FileStat, HostRunner, WriteOptions } from './runner.ts';
|
|
24
24
|
import { assertNoAclGrants } from './sudo-acl.ts';
|
|
25
25
|
import { SudoRefusedError } from './sudo-allowlist.ts';
|
|
26
|
+
import { assertReadableBack, modeProblem } from './sudo-guard-mode.ts';
|
|
27
|
+
|
|
28
|
+
export { assertReadableBack, canRead, modeProblem, operatorGroups } from './sudo-guard-mode.ts';
|
|
26
29
|
|
|
27
30
|
const refuse = (path: string, message: string): Error =>
|
|
28
31
|
new SudoRefusedError(`sudoRunner ${path}: ${message}`);
|
|
@@ -126,23 +129,6 @@ export const assertPlainPath = async (
|
|
|
126
129
|
throw refuse(path, `is ${target === undefined ? 'missing' : `a ${target.kind}`}, not a file`);
|
|
127
130
|
};
|
|
128
131
|
|
|
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
132
|
/**
|
|
147
133
|
* Every check a write under `prefix` meets before anything is staged or run as root: the mode, the
|
|
148
134
|
* path (assertPlainPath) and the read-back (assertReadableBack). ★ Reads only, as the deploying
|
|
@@ -166,65 +152,3 @@ export const assertInstallable = async (
|
|
|
166
152
|
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
167
153
|
await assertReadableBack(base, path, options, groups);
|
|
168
154
|
};
|
|
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
|
-
};
|
|
@@ -34,7 +34,44 @@ describe('modeProblem', () => {
|
|
|
34
34
|
['4755 root is setuid', { mode: 0o4755 }, 'setuid/setgid'],
|
|
35
35
|
['2755 root is setgid', { mode: 0o2755, uid: 0 }, 'setuid/setgid'],
|
|
36
36
|
['6777 root is both', { mode: 0o6777 }, 'setuid/setgid and writable by group or other'],
|
|
37
|
-
[
|
|
37
|
+
[
|
|
38
|
+
'0664 handed to another user AND an explicitly non-root group is theirs to change',
|
|
39
|
+
{ gid: 80, mode: 0o664, uid: OPERATOR },
|
|
40
|
+
undefined,
|
|
41
|
+
],
|
|
42
|
+
// 🔴 Adversarial review, 2026-09-23, round 1: the non-root-uid exemption alone let a
|
|
43
|
+
// setgid-root file through untouched as long as its OWNER was merely non-zero — the group is
|
|
44
|
+
// root's just as much as the owner being uid 0 would be. gid 0 must keep every check active.
|
|
45
|
+
[
|
|
46
|
+
'setgid to root’s own group, handed to another OWNER, is still root’s file to protect',
|
|
47
|
+
{ gid: 0, mode: 0o2775, uid: OPERATOR },
|
|
48
|
+
'setuid/setgid',
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
'group-writable with root’s own group, handed to another owner',
|
|
52
|
+
{ gid: 0, mode: 0o664, uid: OPERATOR },
|
|
53
|
+
'writable by group or other',
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'handed to another owner AND another group is genuinely theirs, setgid included',
|
|
57
|
+
{ gid: 80, mode: 0o2775, uid: OPERATOR },
|
|
58
|
+
undefined,
|
|
59
|
+
],
|
|
60
|
+
// 🔴 Adversarial review, round 2: an OMITTED gid is not "someone else's" either — GNU install,
|
|
61
|
+
// run as root via sudo, defaults a missing -g to root's OWN group whenever the destination
|
|
62
|
+
// directory is not itself setgid (none of this runner's prefixes are), so a `RemoteFile`
|
|
63
|
+
// with an owner but no declared `group` reaches this check exactly as `gid: 0` would, and
|
|
64
|
+
// must be refused exactly as `gid: 0` is above.
|
|
65
|
+
[
|
|
66
|
+
'setgid, handed to another owner, with the group simply never declared',
|
|
67
|
+
{ mode: 0o2775, uid: OPERATOR },
|
|
68
|
+
'setuid/setgid',
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'group-writable, handed to another owner, with the group simply never declared',
|
|
72
|
+
{ mode: 0o664, uid: OPERATOR },
|
|
73
|
+
'writable by group or other',
|
|
74
|
+
],
|
|
38
75
|
] as const)('%s', (_name, options, found) => {
|
|
39
76
|
const problem = modeProblem(options);
|
|
40
77
|
if (found === undefined) expect(problem).toBeUndefined();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "as root" half of fake-sudo.ts: what each allowlisted program does against the SAME
|
|
3
|
+
* in-memory host `fake-linux-host.ts` builds, once `/usr/bin/sudo` has decided to run it.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
|
|
6
|
+
* ★ CHOWN AND MKDIR ARE HANDLED HERE, NOT BY DELEGATING TO `fake.runner.exec()` — the fake's own
|
|
7
|
+
* `chown` refuses when its FIXED `root` flag is false (which it is: the base host models an
|
|
8
|
+
* unprivileged operator), and `mkdir`/`chmod`/`rmdir` do not check ownership at all. Only a
|
|
9
|
+
* handler that writes `fake.dirs` / `fake.modes` / `fake.files` directly proves the runner's
|
|
10
|
+
* OWN allowlist and guard are what gate root, not a permission check the fake happens to have.
|
|
11
|
+
* ★ INSTALL AND MV READ AND WRITE `fake.files` DIRECTLY TOO, because the staged file the operator
|
|
12
|
+
* wrote (via `stageRemote`'s ordinary `writeFileAtomic`) already lives there — there is no
|
|
13
|
+
* separate "staged bytes" side-channel to model, unlike the Mac fake's local temp files.
|
|
14
|
+
*/
|
|
15
|
+
import type { ExecResult, FileStat } from '../launchd/runner.ts';
|
|
16
|
+
import { CHMOD, CHOWN, INSTALL, MKDIR, MV, RM, RMDIR, SYSTEMCTL_ABS } from './sudo-allowlist.ts';
|
|
17
|
+
import type { fakeLinuxHost } from './fake-linux-host.ts';
|
|
18
|
+
|
|
19
|
+
type Fake = ReturnType<typeof fakeLinuxHost>;
|
|
20
|
+
|
|
21
|
+
/** What a test can force `sudo`'s next `mv` / `rm` to answer instead of actually running it. */
|
|
22
|
+
export type RootState = { mvFailure?: ExecResult; rmFailure?: ExecResult };
|
|
23
|
+
|
|
24
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
25
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
26
|
+
const parent = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/';
|
|
27
|
+
|
|
28
|
+
const applyOwner = (live: { uid: number; gid: number }, owner: string): void => {
|
|
29
|
+
const [uidPart, gidPart] = owner.includes(':') ? owner.split(':') : [owner, undefined];
|
|
30
|
+
if (uidPart !== undefined && uidPart !== '') live.uid = Number(uidPart);
|
|
31
|
+
if (gidPart !== undefined && gidPart !== '') live.gid = Number(gidPart);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const dirRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
|
|
35
|
+
const [program, ...args] = argv;
|
|
36
|
+
if (program === MKDIR) {
|
|
37
|
+
const [, mode, , path] = args;
|
|
38
|
+
if (path === undefined) return fail(1, 'mkdir: missing operand');
|
|
39
|
+
fake.dirs.set(path, 0);
|
|
40
|
+
fake.modes.set(path, { gid: 0, mode: Number.parseInt(mode ?? '755', 8), uid: 0 });
|
|
41
|
+
return ok();
|
|
42
|
+
}
|
|
43
|
+
if (program === CHMOD) {
|
|
44
|
+
const [mode, , path] = args;
|
|
45
|
+
const live = path === undefined ? undefined : fake.modes.get(path);
|
|
46
|
+
if (live === undefined) return fail(1, `chmod: ${String(path)}: No such file or directory`);
|
|
47
|
+
live.mode = Number.parseInt(mode ?? '755', 8);
|
|
48
|
+
return ok();
|
|
49
|
+
}
|
|
50
|
+
if (program === RMDIR) {
|
|
51
|
+
const [, path] = args;
|
|
52
|
+
if (path === undefined || !fake.dirs.has(path)) {
|
|
53
|
+
return fail(1, `rmdir: '${String(path)}': No such file or directory`);
|
|
54
|
+
}
|
|
55
|
+
if ([...fake.files.keys()].some((file) => parent(file) === path)) {
|
|
56
|
+
return fail(1, `rmdir: failed to remove '${path}': Directory not empty`);
|
|
57
|
+
}
|
|
58
|
+
fake.dirs.delete(path);
|
|
59
|
+
fake.modes.delete(path);
|
|
60
|
+
return ok();
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fileRoot = (
|
|
66
|
+
fake: Fake,
|
|
67
|
+
argv: readonly string[],
|
|
68
|
+
state: RootState,
|
|
69
|
+
): ExecResult | undefined => {
|
|
70
|
+
const [program, ...args] = argv;
|
|
71
|
+
if (program === INSTALL) {
|
|
72
|
+
const tIndex = args.indexOf('-T');
|
|
73
|
+
const [source, dest] = args.slice(-2);
|
|
74
|
+
const ids = args.slice(2, tIndex);
|
|
75
|
+
const owned = ids.includes('-o') ? Number(ids[ids.indexOf('-o') + 1]) : 0;
|
|
76
|
+
const grouped = ids.includes('-g') ? Number(ids[ids.indexOf('-g') + 1]) : 0;
|
|
77
|
+
const bytes = source === undefined ? undefined : fake.files.get(source)?.bytes;
|
|
78
|
+
if (bytes === undefined || dest === undefined) {
|
|
79
|
+
return fail(1, `install: cannot stat '${String(source)}': No such file or directory`);
|
|
80
|
+
}
|
|
81
|
+
const kind: FileStat['kind'] = 'file';
|
|
82
|
+
fake.files.set(dest, {
|
|
83
|
+
bytes,
|
|
84
|
+
gid: grouped,
|
|
85
|
+
kind,
|
|
86
|
+
mode: Number.parseInt(args[1] ?? '644', 8),
|
|
87
|
+
uid: owned,
|
|
88
|
+
});
|
|
89
|
+
return ok();
|
|
90
|
+
}
|
|
91
|
+
if (program === MV) {
|
|
92
|
+
if (state.mvFailure !== undefined) {
|
|
93
|
+
const failure = state.mvFailure;
|
|
94
|
+
delete state.mvFailure; // ★ once, like a real transient failure — not every mv after it
|
|
95
|
+
return failure;
|
|
96
|
+
}
|
|
97
|
+
const [source, dest] = args.slice(-2);
|
|
98
|
+
const entry = source === undefined ? undefined : fake.files.get(source);
|
|
99
|
+
if (entry === undefined || dest === undefined) {
|
|
100
|
+
return fail(1, `mv: cannot stat '${String(source)}': No such file or directory`);
|
|
101
|
+
}
|
|
102
|
+
fake.files.delete(source ?? '');
|
|
103
|
+
fake.files.set(dest, entry);
|
|
104
|
+
return ok();
|
|
105
|
+
}
|
|
106
|
+
if (program === RM) {
|
|
107
|
+
if (state.rmFailure !== undefined) {
|
|
108
|
+
const failure = state.rmFailure;
|
|
109
|
+
delete state.rmFailure; // ★ once, like a real transient failure
|
|
110
|
+
return failure;
|
|
111
|
+
}
|
|
112
|
+
const [, , path] = args;
|
|
113
|
+
if (path !== undefined) fake.files.delete(path);
|
|
114
|
+
return ok();
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `chown` on either a directory (`fake.modes`) or a file (`fake.files` — the derived temp,
|
|
121
|
+
* `sudo-write.ts`'s own non-root-owner step). `applyOwner` accepts the `+`-forced spec
|
|
122
|
+
* unchanged: `Number('+501')` parses to `501` in JS, same as a bare digit string.
|
|
123
|
+
*/
|
|
124
|
+
const chownRoot = (fake: Fake, argv: readonly string[]): ExecResult | undefined => {
|
|
125
|
+
if (argv[0] !== CHOWN) return undefined;
|
|
126
|
+
const [owner, , path] = argv.slice(1);
|
|
127
|
+
if (owner === undefined || path === undefined) {
|
|
128
|
+
return fail(1, `chown: ${String(path)}: No such file or directory`);
|
|
129
|
+
}
|
|
130
|
+
const dir = fake.modes.get(path);
|
|
131
|
+
if (dir !== undefined) {
|
|
132
|
+
applyOwner(dir, owner);
|
|
133
|
+
return ok();
|
|
134
|
+
}
|
|
135
|
+
const file = fake.files.get(path);
|
|
136
|
+
if (file !== undefined) {
|
|
137
|
+
applyOwner(file, owner);
|
|
138
|
+
return ok();
|
|
139
|
+
}
|
|
140
|
+
return fail(1, `chown: ${path}: No such file or directory`);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Everything `/usr/bin/sudo -n --` may run, dispatched as root against the same fake host. */
|
|
144
|
+
export const asRoot = async (
|
|
145
|
+
fake: Fake,
|
|
146
|
+
argv: readonly string[],
|
|
147
|
+
state: RootState = {},
|
|
148
|
+
): Promise<ExecResult> => {
|
|
149
|
+
const chown = chownRoot(fake, argv);
|
|
150
|
+
if (chown !== undefined) return chown;
|
|
151
|
+
const dir = dirRoot(fake, argv);
|
|
152
|
+
if (dir !== undefined) return dir;
|
|
153
|
+
const file = fileRoot(fake, argv, state);
|
|
154
|
+
if (file !== undefined) return file;
|
|
155
|
+
if (argv[0] === SYSTEMCTL_ABS) return fake.runner.exec(['systemctl', ...argv.slice(1)]);
|
|
156
|
+
return fail(1, `sudo: ${String(argv[0])}: command not found`);
|
|
157
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshSudoRunner over fake-linux-host.ts, for the tests beside it: the operator is uid 1000 and
|
|
3
|
+
* unprivileged, and `/usr/bin/sudo` is a tiny interpreter (fake-sudo-root.ts) that runs the
|
|
4
|
+
* allowlisted programs against the SAME in-memory host, as root. No test anywhere runs ssh or a
|
|
5
|
+
* real sudo.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY, like fake-linux-host.ts: no provider imports it, it is not on the barrel.
|
|
8
|
+
* ★ `ls -ldn` AND `mktemp -d` ARE MODELLED HERE, AS THE OPERATOR — sudo-guard.ts's chain read and
|
|
9
|
+
* sudo-stage.ts's staging both run unprivileged, so both need a host that answers them without
|
|
10
|
+
* sudo. `ls` reads through `fake.runner.stat()` (already correct for declared dirs, their
|
|
11
|
+
* implied root-owned ancestors, and files) and formats one `ls -ldn`-shaped line per path;
|
|
12
|
+
* `mktemp -d` registers a fresh 0700 directory the operator owns.
|
|
13
|
+
* ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
14
|
+
* before it runs", exactly as the Mac fake does.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExecResult, FileStat, HostRunner } from '../launchd/runner.ts';
|
|
17
|
+
import { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
18
|
+
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
19
|
+
import { type RootState, asRoot } from './fake-sudo-root.ts';
|
|
20
|
+
import { SUDO } from './sudo-allowlist.ts';
|
|
21
|
+
import { makeSshSudoRunner } from './sudo-runner.ts';
|
|
22
|
+
import { stageRemote } from './sudo-stage.ts';
|
|
23
|
+
|
|
24
|
+
export { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
25
|
+
|
|
26
|
+
export const OPERATOR = 1000;
|
|
27
|
+
export const PREFIXES = ['/etc/systemd/system', '/usr/local/bin'];
|
|
28
|
+
|
|
29
|
+
const ok = (stdout = ''): ExecResult => ({ exitCode: 0, stderr: '', stdout });
|
|
30
|
+
const fail = (exitCode: number, stderr: string): ExecResult => ({ exitCode, stderr, stdout: '' });
|
|
31
|
+
|
|
32
|
+
const permString = (mode: number): string => {
|
|
33
|
+
const triplet = (shift: number, special: number, ch: string, upper: string) => {
|
|
34
|
+
const r = (mode & (0o4 << shift)) === 0 ? '-' : 'r';
|
|
35
|
+
const w = (mode & (0o2 << shift)) === 0 ? '-' : 'w';
|
|
36
|
+
const exec = (mode & (0o1 << shift)) !== 0;
|
|
37
|
+
const marked = (mode & special) !== 0;
|
|
38
|
+
return `${r}${w}${marked ? (exec ? ch : upper) : exec ? 'x' : '-'}`;
|
|
39
|
+
};
|
|
40
|
+
return `${triplet(6, 0o4000, 's', 'S')}${triplet(3, 0o2000, 's', 'S')}${triplet(0, 0o1000, 't', 'T')}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const lsLine = (path: string, stat: FileStat, acl: boolean): string => {
|
|
44
|
+
const kind = stat.kind === 'directory' ? 'd' : stat.kind === 'symlink' ? 'l' : '-';
|
|
45
|
+
const target = stat.kind === 'symlink' ? ` -> ${path}.target` : '';
|
|
46
|
+
return (
|
|
47
|
+
`${kind}${permString(stat.mode)}${acl ? '+' : ''} 1 ${String(stat.uid)} ${String(stat.gid)} ` +
|
|
48
|
+
`${String(stat.size)} Sep 22 10:12 ${path}${target}`
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `extraDirs` adds more directories to the fake filesystem (a pre-existing tree outside every
|
|
54
|
+
* prefix, say); it never changes the runner's OWN prefixes, which stay `PREFIXES` throughout.
|
|
55
|
+
* `acl` is the set of directory paths a test wants to carry the `+` (POSIX ACL) flag.
|
|
56
|
+
*/
|
|
57
|
+
export const fakeSudoHost = (
|
|
58
|
+
extraDirs: Record<string, number> = {},
|
|
59
|
+
acl: ReadonlySet<string> = new Set(),
|
|
60
|
+
) => {
|
|
61
|
+
const fake = fakeLinuxHost({
|
|
62
|
+
dirs: { '/etc/systemd/system': 0, '/usr/local/bin': 0, ...extraDirs },
|
|
63
|
+
euid: OPERATOR,
|
|
64
|
+
users: { app: { gid: 60, home: '/opt/app', uid: 900 } },
|
|
65
|
+
});
|
|
66
|
+
let mktempCount = 0;
|
|
67
|
+
const logs: string[] = [];
|
|
68
|
+
const sudoCalls: string[][] = [];
|
|
69
|
+
const state: RootState & { sudo: 'ok' | keyof typeof SUDO_SAYS } = { sudo: 'ok' };
|
|
70
|
+
|
|
71
|
+
const handleLs = async (paths: readonly string[]): Promise<ExecResult> => {
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
for (const path of paths) {
|
|
74
|
+
const stat = await fake.runner.stat(path);
|
|
75
|
+
if (stat === undefined)
|
|
76
|
+
return fail(2, `ls: cannot access '${path}': No such file or directory`);
|
|
77
|
+
lines.push(lsLine(path, stat, acl.has(path)));
|
|
78
|
+
}
|
|
79
|
+
return ok(`${lines.join('\n')}\n`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleMktemp = (): ExecResult => {
|
|
83
|
+
mktempCount += 1;
|
|
84
|
+
const dir = `/tmp/hf-sudo-${String(mktempCount)}`;
|
|
85
|
+
fake.dirs.set(dir, OPERATOR);
|
|
86
|
+
fake.modes.set(dir, { gid: 0, mode: 0o700, uid: OPERATOR });
|
|
87
|
+
return ok(`${dir}\n`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `rm -rf -- <dir>`, as the operator — `sudo-stage.ts`'s own cleanup of its staging directory. */
|
|
91
|
+
const handleRm = (argv: readonly string[]): ExecResult => {
|
|
92
|
+
const target = argv.at(-1);
|
|
93
|
+
if (target === undefined) return fail(1, 'rm: missing operand');
|
|
94
|
+
const under = (path: string) => path === target || path.startsWith(`${target}/`);
|
|
95
|
+
for (const path of [...fake.files.keys()].filter(under)) fake.files.delete(path);
|
|
96
|
+
for (const path of [...fake.dirs.keys()].filter(under)) {
|
|
97
|
+
fake.dirs.delete(path);
|
|
98
|
+
fake.modes.delete(path);
|
|
99
|
+
}
|
|
100
|
+
return ok();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const base: HostRunner = {
|
|
104
|
+
...fake.runner,
|
|
105
|
+
exec: async (argv) => {
|
|
106
|
+
// ★ Read-only, operator-level calls now go by absolute path too (cosmetic hardening from
|
|
107
|
+
// the 2026-09-23 adversarial review — see the sources' own headers); fakeLinuxHost's own
|
|
108
|
+
// dispatcher only recognises the bare names it was built against, so both spellings are
|
|
109
|
+
// handled here and `systemctl`'s absolute form is translated back to bare before it is
|
|
110
|
+
// handed off, rather than teaching the shared fixture a second spelling.
|
|
111
|
+
if (argv[0] === 'ls' || argv[0] === '/usr/bin/ls') {
|
|
112
|
+
if (argv[1] === '-ldn') return handleLs(argv.slice(3));
|
|
113
|
+
}
|
|
114
|
+
if ((argv[0] === 'mktemp' || argv[0] === '/usr/bin/mktemp') && argv[1] === '-d') {
|
|
115
|
+
return handleMktemp();
|
|
116
|
+
}
|
|
117
|
+
if (argv[0] === 'rm' || argv[0] === '/usr/bin/rm') return handleRm(argv);
|
|
118
|
+
if (argv[0] === '/usr/bin/systemctl')
|
|
119
|
+
return fake.runner.exec(['systemctl', ...argv.slice(1)]);
|
|
120
|
+
if (argv[0] !== SUDO) return fake.runner.exec(argv);
|
|
121
|
+
sudoCalls.push([...argv]);
|
|
122
|
+
if (logs[logs.length - 1] !== `homeflare/linux sudo -n ${JSON.stringify(argv.slice(3))}`) {
|
|
123
|
+
throw new Error(`fake sudo: ${JSON.stringify(argv)} was not logged before it ran`);
|
|
124
|
+
}
|
|
125
|
+
if (state.sudo !== 'ok') return fail(1, SUDO_SAYS[state.sudo]);
|
|
126
|
+
return asRoot(fake, argv.slice(3), state);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const runner = makeSshSudoRunner(PREFIXES, {
|
|
131
|
+
base,
|
|
132
|
+
log: (line) => logs.push(line),
|
|
133
|
+
// ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
|
|
134
|
+
// of which this fake's `mktemp`/`ls`/operator-level paths already answer.
|
|
135
|
+
stage: stageRemote(base),
|
|
136
|
+
});
|
|
137
|
+
/** The privileged commands run, without the `sudo -n --` in front. */
|
|
138
|
+
const privileged = () => sudoCalls.map((call) => call.slice(3));
|
|
139
|
+
// ★ `base` (unprivileged, but with `ls`/`mktemp` support) is exposed for sudo-guard.test.ts,
|
|
140
|
+
// which exercises assertGuardedChain directly, below the runner it is a building block of.
|
|
141
|
+
return { base, fake, logs, privileged, runner, state };
|
|
142
|
+
};
|
package/src/linux/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type { RemoteFileAttributes, RemoteFileProps } from './remote-file-form.t
|
|
|
19
19
|
export { RemoteFile, RemoteFileProvider } from './remote-file.ts';
|
|
20
20
|
export type { SshRunnerOptions } from './ssh-runner.ts';
|
|
21
21
|
export { sshRunner } from './ssh-runner.ts';
|
|
22
|
+
export type { SshSudoRunnerOptions } from './sudo-runner.ts';
|
|
23
|
+
export { sshSudoRunner } from './sudo-runner.ts';
|
|
22
24
|
export type {
|
|
23
25
|
ExecResult,
|
|
24
26
|
FileStat,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chown`'s two privileged shapes, split out of sudo-allowlist.ts to keep it under the house's
|
|
3
|
+
* 250-line cap: the ROOT-ONLY directory form (sudo-allowlist-dir.ts owns that check) and the
|
|
4
|
+
* FILE-ownership form used only on the derived temp file `sudo-write.ts` `install`s into, for a
|
|
5
|
+
* `RemoteFile` whose declared owner or group is not root.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ `install`'S OWN `-o`/`-g` ARE NEVER USED, ON EITHER PLATFORM'S ALLOWLIST NOW. 🔴 MEASURED
|
|
8
|
+
* (adversarial review, round 2, 2026-09-23, against coreutils 9.7 `src/install.c` `get_ids()`):
|
|
9
|
+
* unlike `chown`, `install` does NOT go through gnulib's `+`-aware `userspec.c` at all — its own
|
|
10
|
+
* `get_ids()` calls `getpwnam(owner_name)` / `getgrnam(group_name)` FIRST for ANY string,
|
|
11
|
+
* falling back to `xstrtoumax` only when no such account exists, with no `+`-prefix escape of
|
|
12
|
+
* any kind. So `install -o 0` on a host with a user or group literally named `"0"` installs
|
|
13
|
+
* owned by THAT account, not uid 0 — and there is no argv shape that closes this for `install`
|
|
14
|
+
* itself. The fix is to never let `install` set ownership: it always runs with no `-o`/`-g` (so
|
|
15
|
+
* the fresh temp file lands owned by whoever `sudo` runs it as — root:root, with no name lookup
|
|
16
|
+
* at all, since omitting the flags skips `get_ids` entirely), and this file's `chown` shape sets
|
|
17
|
+
* the FINAL ownership afterward, on the same temp file, before `mv`. `chown` DOES support `+`
|
|
18
|
+
* (gnulib `userspec.c`: `*u == '+' ? NULL : getpwnam(u)`, "if it starts with '+', skip the
|
|
19
|
+
* look-up") — repeated on each side of a `user:group` spec independently — so this is where the
|
|
20
|
+
* fix actually lands: `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>`, never a bare digit string.
|
|
21
|
+
*/
|
|
22
|
+
import { CHOWN, dirProgramProblem } from './sudo-allowlist-dir.ts';
|
|
23
|
+
|
|
24
|
+
/** Bare `directory-lifecycle.ts` argv (`0`, `:0`, `0:0`, …) → the `+`-forced form sudo may run. */
|
|
25
|
+
export const forceNumericOwner = (spec: string): string => {
|
|
26
|
+
const [uidPart, gidPart] = spec.includes(':') ? spec.split(':') : [spec, undefined];
|
|
27
|
+
const uid = uidPart === undefined || uidPart === '' ? '' : `+${uidPart}`;
|
|
28
|
+
const gid = gidPart === undefined ? '' : `:+${gidPart}`;
|
|
29
|
+
return `${uid}${gid}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** `+<uid>`, `:+<gid>`, or `+<uid>:+<gid>` — numeric, `+`-forced, either or both sides. */
|
|
33
|
+
const SAFE_OWNER = /^(\+\d+(:\+\d+)?|:\+\d+)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `chown <safe spec> -- <temp>`, bound to the exact temp `install` just wrote — the file-ownership
|
|
37
|
+
* half of `writeUnderPrefix`'s sequence. Any owner/group is allowed (a `RemoteFile` may declare
|
|
38
|
+
* one), as long as it is numeric and forced; the temp binding is what stops it reaching anywhere
|
|
39
|
+
* else, the same way `mv`'s own binding does.
|
|
40
|
+
*/
|
|
41
|
+
const fileChownProblem = (args: readonly string[], temp: string): string | undefined => {
|
|
42
|
+
const [owner, end, path, ...extra] = args;
|
|
43
|
+
if (
|
|
44
|
+
owner !== undefined &&
|
|
45
|
+
SAFE_OWNER.test(owner) &&
|
|
46
|
+
end === '--' &&
|
|
47
|
+
extra.length === 0 &&
|
|
48
|
+
path === temp
|
|
49
|
+
) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
'chown on the staged temp takes exactly `+<uid>`, `:+<gid>` or `+<uid>:+<gid>` -- ' +
|
|
54
|
+
'<the exact temp install just wrote>, numeric and +-forced'
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `chown`'s dispatch: a call bound to `context.temp` (its last argument equals it) is the
|
|
60
|
+
* file-ownership form above; everything else is the directory form, root-only
|
|
61
|
+
* (`sudo-allowlist-dir.ts`'s own `ROOT_OWNER`, `+`-forced the same way).
|
|
62
|
+
*/
|
|
63
|
+
export const chownProblem = (
|
|
64
|
+
args: readonly string[],
|
|
65
|
+
context: { readonly prefixes: readonly string[]; readonly temp?: string },
|
|
66
|
+
): string | undefined => {
|
|
67
|
+
const path = args[args.length - 1];
|
|
68
|
+
if (context.temp !== undefined && path === context.temp)
|
|
69
|
+
return fileChownProblem(args, context.temp);
|
|
70
|
+
return dirProgramProblem(CHOWN, args, context.prefixes);
|
|
71
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mkdir` / `chmod` / `chown` / `rmdir` half of sudo-allowlist.ts, split out to keep that file
|
|
3
|
+
* under the house's 250-line cap. These four are `Host.Directory`'s whole write surface
|
|
4
|
+
* (directory-lifecycle.ts): this file fixes their argv to the EXACT shapes that file emits.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ `chown` TAKES ONLY NUMERIC IDS, NEVER A NAME. `directory-lifecycle.ts`'s `ownerArg()` already
|
|
7
|
+
* resolves a name to a uid/gid before building argv, so a name here would mean some other code
|
|
8
|
+
* path built this argv — refused rather than trusted.
|
|
9
|
+
* 🔴 MEASURED (adversarial review, 2026-09-23, against this file's first shipped version): NEITHER
|
|
10
|
+
* the mode nor the owner was checked for anything but shape. `mkdir -m 0777 -- <path>` and
|
|
11
|
+
* `chown 1001:1001 -- <path>` both passed the allowlist untouched. A stack could declare
|
|
12
|
+
* `HostDirectory({ path: '/etc/systemd/system/x.service.d', mode: 0o777 })`, drop a
|
|
13
|
+
* `ExecStart=` override into it, and the runner's own next `daemon-reload` + `restart` would run
|
|
14
|
+
* it as root — a local privilege escalation reachable through this runner's own allowlist, not a
|
|
15
|
+
* bug in the guard (which never runs against a path this permissive by design).
|
|
16
|
+
* ⛔ SO: NO SPECIAL BITS, NO GROUP/OTHER WRITE, EVER — mirroring the file half's `modeProblem`
|
|
17
|
+
* (`../launchd/sudo-guard.ts`) but WITHOUT its "handed to another owner" exemption. A directory's
|
|
18
|
+
* OWNER always has write access through the owner bits alone, so no mode restriction can make a
|
|
19
|
+
* non-root-owned directory here safe — which is why `chown` below is refused to anything but
|
|
20
|
+
* root outright, not merely mode-restricted.
|
|
21
|
+
* 🔴 MEASURED (adversarial review, round 2, 2026-09-23, cited against gnulib's `lib/userspec.c`,
|
|
22
|
+
* used by GNU `chown`): a purely-numeric owner string is looked up as a NAME first —
|
|
23
|
+
* `*u == '+' ? NULL : getpwnam(u)` — and only parsed as a raw number when no such account
|
|
24
|
+
* exists. If a host ever had a user or group literally NAMED `"0"`, `chown 0 -- path` would
|
|
25
|
+
* silently chown to THAT account, not uid 0 — bypassing this file's own root-only restriction
|
|
26
|
+
* without tripping it. `+` skips the name lookup outright (`if it starts with '+', skip the
|
|
27
|
+
* look-up`, `userspec.c`), so the allowlist now requires it: `sudo-allowlist.ts`'s
|
|
28
|
+
* `canonicalize()` rewrites the bare `0`/`:0`/`0:0` argv `directory-lifecycle.ts` actually sends
|
|
29
|
+
* into the forced-numeric form below before this check ever runs, so the unprivileged caller
|
|
30
|
+
* never has to know this syntax exists.
|
|
31
|
+
*/
|
|
32
|
+
import { prefixOf } from '../launchd/sudo-allowlist.ts';
|
|
33
|
+
|
|
34
|
+
export const MKDIR = '/usr/bin/mkdir';
|
|
35
|
+
export const CHMOD = '/usr/bin/chmod';
|
|
36
|
+
export const CHOWN = '/usr/bin/chown';
|
|
37
|
+
export const RMDIR = '/usr/bin/rmdir';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `mkdir -m` / `chmod`'s mode, exactly as `directory-lifecycle.ts`'s own `octal()` formats it:
|
|
41
|
+
* `mode.toString(8).padStart(3, '0')` — 3 digits, or 4 when a special bit makes it longer.
|
|
42
|
+
* Parsed to a number so the setuid/setgid/group/other-write bits can be checked directly, the same
|
|
43
|
+
* mask `modeProblem` uses (`0o6000 | 0o022`).
|
|
44
|
+
*/
|
|
45
|
+
const parseDirMode = (text: string): number | undefined =>
|
|
46
|
+
/^([0-7]{3}|[1-7][0-7]{3})$/.test(text) ? Number.parseInt(text, 8) : undefined;
|
|
47
|
+
|
|
48
|
+
const DANGEROUS_MODE_BITS = 0o6022;
|
|
49
|
+
|
|
50
|
+
/** `undefined` when `mode` is a safe directory mode: no setuid/setgid, no group/other write. */
|
|
51
|
+
const dirModeProblem = (mode: number | undefined): string | undefined => {
|
|
52
|
+
if (mode === undefined) return 'mode';
|
|
53
|
+
return (mode & DANGEROUS_MODE_BITS) !== 0
|
|
54
|
+
? 'a mode with setuid/setgid or group/other write (this runner installs only 0–0o777 minus 0o022, no special bits)'
|
|
55
|
+
: undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `chown`'s owner argument — root only, and `+`-forced so a same-named account can never be
|
|
60
|
+
* resolved instead of uid/gid `0`. `canonicalize()` builds these from the bare `0`/`:0`/`0:0`
|
|
61
|
+
* `directory-lifecycle.ts`'s `ownerArg()` sends; this checks what actually reaches `sudo`.
|
|
62
|
+
*/
|
|
63
|
+
const ROOT_OWNER = /^(\+0|:\+0|\+0:\+0)$/;
|
|
64
|
+
|
|
65
|
+
export const dirProgramProblem = (
|
|
66
|
+
program: string,
|
|
67
|
+
args: readonly string[],
|
|
68
|
+
prefixes: readonly string[],
|
|
69
|
+
): string | undefined => {
|
|
70
|
+
const under = (path: string | undefined) =>
|
|
71
|
+
path !== undefined && prefixOf(path, prefixes) !== undefined;
|
|
72
|
+
if (program === MKDIR) {
|
|
73
|
+
const [m, modeText, end, path, ...extra] = args;
|
|
74
|
+
const mode = modeText === undefined ? undefined : parseDirMode(modeText);
|
|
75
|
+
if (m !== '-m' || mode === undefined || end !== '--' || extra.length !== 0 || !under(path)) {
|
|
76
|
+
return 'mkdir takes exactly `-m <octal> -- <path under a declared prefix>`';
|
|
77
|
+
}
|
|
78
|
+
return dirModeProblem(mode);
|
|
79
|
+
}
|
|
80
|
+
if (program === CHMOD) {
|
|
81
|
+
const [modeText, end, path, ...extra] = args;
|
|
82
|
+
const mode = modeText === undefined ? undefined : parseDirMode(modeText);
|
|
83
|
+
if (mode === undefined || end !== '--' || extra.length !== 0 || !under(path)) {
|
|
84
|
+
return 'chmod takes exactly `<octal> -- <path under a declared prefix>`';
|
|
85
|
+
}
|
|
86
|
+
return dirModeProblem(mode);
|
|
87
|
+
}
|
|
88
|
+
if (program === CHOWN) {
|
|
89
|
+
const [owner, end, path, ...extra] = args;
|
|
90
|
+
return owner !== undefined &&
|
|
91
|
+
ROOT_OWNER.test(owner) &&
|
|
92
|
+
end === '--' &&
|
|
93
|
+
extra.length === 0 &&
|
|
94
|
+
under(path)
|
|
95
|
+
? undefined
|
|
96
|
+
: 'chown takes exactly `+0`, `:+0` or `+0:+0` -- <path under a declared prefix>` — root ' +
|
|
97
|
+
'only, forced numeric; a directory this runner elevates never hands ownership to anyone else';
|
|
98
|
+
}
|
|
99
|
+
const [end, path, ...extra] = args;
|
|
100
|
+
return end === '--' && extra.length === 0 && under(path)
|
|
101
|
+
? undefined
|
|
102
|
+
: 'rmdir takes exactly `-- <path under a declared prefix>`';
|
|
103
|
+
};
|