Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_gitpush.mjs

11.0 KiB, 1 run

created by r2519314175:437, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// Verify the push configuration against the real `git` binary.
2//
3// The Rust tests in `src/tools.rs` prove that Daimond builds the strings it means to build. They
4// cannot prove that git DOES anything with them, and every one of those strings is load-bearing:
5// if `GIT_CONFIG_COUNT` were not read on this git, the push would go out unauthenticated; if
6// `credential.helper=""` did not reset the list, a helper written into a repository's own config
7// would still run with the credential in its environment; if `protocol.allow=never` did not close
8// `ext::`, the remote's URL would be a command.
9//
10// So this asks git. Nothing here reaches the network and no credential is used: the token is a
11// literal `NOT-A-REAL-TOKEN`, the remotes are never contacted, and every check is answered by git
12// reading its own configuration.
13//
14// Run: node dev/verify_gitpush.mjs
15
16import { execFileSync } from 'node:child_process';
17import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
18import { homedir } from 'node:os';
19import { join } from 'node:path';
20
21const WORK = join(homedir(), '.cache', 'daimond-verify', 'gitpush');
22const HOST = 'github.com';
23const TOKEN = 'NOT-A-REAL-TOKEN'; // allowlist secret
24const USER = 'x-access-token';
25
26/// The environment `PushCred::git_env` builds, restated here so a drift between the two shows up
27/// as this verifier passing while the Rust test fails, or the reverse.
28function pushEnv(root) {
29 const base = `https://${HOST}/`;
30 const cfg = [
31 [`url.${base}.insteadOf`, `git@${HOST}:`],
32 [`url.${base}.insteadOf`, `ssh://git@${HOST}/`],
33 [`http.${base}.extraHeader`, `Authorization: Basic ${Buffer.from(`${USER}:${TOKEN}`).toString('base64')}`],
34 ['credential.helper', ''],
35 ['protocol.allow', 'never'],
36 ['protocol.https.allow', 'always'],
37 ['core.hooksPath', `${root}/.daimond/no-hooks`],
38 ];
39 const env = { GIT_CONFIG_COUNT: String(cfg.length), GIT_TERMINAL_PROMPT: '0' };
40 cfg.forEach(([k, v], i) => { env[`GIT_CONFIG_KEY_${i}`] = k; env[`GIT_CONFIG_VALUE_${i}`] = v; });
41 return env;
42}
43
44let failed = 0;
45function check(name, fn) {
46 try {
47 fn();
48 console.log(` ok ${name}`);
49 } catch (e) {
50 failed += 1;
51 console.log(` FAIL ${name}\n ${String(e.message).split('\n').join('\n ')}`);
52 }
53}
54
55function git(args, opts = {}) {
56 return execFileSync('git', args, {
57 cwd: opts.cwd || WORK,
58 encoding: 'utf8',
59 stdio: ['ignore', 'pipe', 'pipe'],
60 // A clean slate: the verifier's own shell environment must not decide any of this.
61 env: { PATH: process.env.PATH, HOME: WORK, ...(opts.env || {}) },
62 }).trim();
63}
64
65function gitFails(args, opts = {}) {
66 try {
67 git(args, opts);
68 } catch (e) {
69 return `${e.stderr || ''}${e.stdout || ''}`;
70 }
71 throw new Error('the command succeeded, and it was supposed to be refused');
72}
73
74rmSync(WORK, { recursive: true, force: true });
75mkdirSync(WORK, { recursive: true });
76git(['init', '-q', '.']);
77git(['remote', 'add', 'origin', `git@${HOST}:oxedyne-com/fe2o3.git`]);
78const ENV = pushEnv(WORK);
79
80console.log(`git ${git(['--version'])}\n`);
81
82// GIT_CONFIG_COUNT is the whole channel. Git 2.31 introduced it; an older git would read none of
83// this and push with no credential and no protocol restriction at all.
84check('git reads configuration from the environment at all', () => {
85 const listed = git(['config', '--list'], { env: ENV });
86 if (!listed.includes('protocol.allow=never')) {
87 throw new Error(`GIT_CONFIG_COUNT was not honoured -- this git is too old.\n${listed}`);
88 }
89});
90
91// The user's remote is SSH and must be rewritten for the push. `--get-url` applies `insteadOf`
92// and contacts nothing.
93check('an SSH remote is rewritten to HTTPS for the push', () => {
94 const url = git(['ls-remote', '--get-url', 'origin'], { env: ENV });
95 if (url !== `https://${HOST}/oxedyne-com/fe2o3.git`) {
96 throw new Error(`insteadOf did not rewrite the remote: ${url}`);
97 }
98});
99
100check('the same rewrite covers the ssh:// spelling', () => {
101 git(['remote', 'add', 'alt', `ssh://git@${HOST}/o/r.git`]);
102 const url = git(['ls-remote', '--get-url', 'alt'], { env: ENV });
103 git(['remote', 'remove', 'alt']);
104 if (url !== `https://${HOST}/o/r.git`) {
105 throw new Error(`the ssh:// spelling was not rewritten: ${url}`);
106 }
107});
108
109// Untouched without the environment, which is what "nothing is written to .git/config" means.
110check('nothing is written to the repository: the remote is unchanged without the environment', () => {
111 const url = git(['ls-remote', '--get-url', 'origin']);
112 if (url !== `git@${HOST}:oxedyne-com/fe2o3.git`) {
113 throw new Error(`the repository's own configuration was changed: ${url}`);
114 }
115 const listed = git(['config', '--list', '--local']);
116 if (/extraheader|credential|protocol\.allow|hookspath/i.test(listed)) {
117 throw new Error(`push configuration was persisted to disc:\n${listed}`);
118 }
119});
120
121// The credential is scoped to one host. This is the check that matters most: `.git/config` is a
122// file the model can WRITE, so `remote.origin.pushurl` can name a host of its own choosing, and an
123// unscoped `http.extraHeader` would follow it there.
124check('the credential is scoped to one host and does not follow a redirected push', () => {
125 const mine = git(['config', '--get-urlmatch', 'http', `https://${HOST}/o/r`], { env: ENV });
126 if (!mine.includes('Authorization: Basic')) {
127 throw new Error(`the header does not apply to its own host:\n${mine}`);
128 }
129 let theirs = '';
130 try {
131 theirs = git(['config', '--get-urlmatch', 'http', 'https://evil.test/o/r'], { env: ENV });
132 } catch (e) {
133 theirs = ''; // no match at all, which is the right answer
134 }
135 if (theirs.includes('Authorization')) {
136 throw new Error(`the credential would be sent to another host:\n${theirs}`);
137 }
138});
139
140// A helper in the repository's own config would run with the credential in its environment. An
141// empty value resets the list, and the environment is read last, so it clears what came before.
142//
143// Asked by RUNNING the credential machinery and not by listing the configuration: `git config
144// --get-all credential.helper` prints the raw values in file order and knows nothing about the
145// reset, so it reports the helper as present whether or not git would ever call it. That reading
146// is what this check first made, and it was wrong in the safe direction only by luck.
147check('a credential helper written into the repository is cleared, not run', () => {
148 const ask = ['credential', 'fill'];
149 const stdin = 'protocol=https\nhost=example.invalid\n\n';
150 git(['config', '--local', 'credential.helper', '!f() { echo username=stolen; echo password=stolen; }; f']);
151 // The control: without the environment the helper IS called, so a check that saw nothing
152 // would be seeing a helper that never ran rather than one that was reset.
153 let control = '';
154 try {
155 control = execFileSync('git', ask, {
156 cwd: WORK, input: stdin, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
157 env: { PATH: process.env.PATH, HOME: WORK, GIT_TERMINAL_PROMPT: '0' },
158 });
159 } catch (e) {
160 control = `${e.stdout || ''}${e.stderr || ''}`;
161 }
162 let withEnv = '';
163 try {
164 withEnv = execFileSync('git', ask, {
165 cwd: WORK, input: stdin, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
166 env: { PATH: process.env.PATH, HOME: WORK, ...ENV },
167 });
168 } catch (e) {
169 withEnv = `${e.stdout || ''}${e.stderr || ''}`;
170 }
171 git(['config', '--local', '--unset-all', 'credential.helper']);
172 if (!control.includes('stolen')) {
173 throw new Error(`the control did not run the helper, so this check proves nothing:\n${control}`);
174 }
175 if (withEnv.includes('stolen')) {
176 throw new Error(`a helper in the repository's own config still ran:\n${withEnv}`);
177 }
178});
179
180// `ext::` runs a command named in the remote's URL, and the remote's URL is in a file the model
181// can write. Nothing here contacts anything: the refusal happens before the transport starts.
182check('the ext:: transport is closed, so a remote URL cannot be a command', () => {
183 const err = gitFails(['ls-remote', 'ext::sh -c "echo pwned"'], { env: ENV });
184 if (!/transport.*not allowed|protocol.*not supported|not allowed/i.test(err)) {
185 throw new Error(`ext:: was not refused for the right reason:\n${err}`);
186 }
187 // And the one protocol a push needs is still allowed, which is what makes this a fence and
188 // not an outage: `--get-url` on the rewritten HTTPS remote resolves without complaint.
189 git(['ls-remote', '--get-url', 'origin'], { env: ENV });
190});
191
192// A fence nothing can go out through is not a fence but an outage, so the one protocol a push
193// needs must still be open. Aimed at a port nothing listens on, so the answer distinguishes "the
194// transport was refused" from "the transport ran and could not connect" without leaving the
195// machine.
196check('https is still open, so the push itself is not what got closed', () => {
197 const err = gitFails(['ls-remote', 'https://127.0.0.1:1/x.git'], { env: ENV });
198 if (/not allowed|not supported/i.test(err)) {
199 throw new Error(`protocol.allow closed the one protocol a push needs:\n${err}`);
200 }
201 if (!/connect|refused|could not read|unable to access|port/i.test(err)) {
202 throw new Error(`https failed for a reason that is not a connection failure:\n${err}`);
203 }
204});
205
206check('the file:: transport is closed too', () => {
207 const err = gitFails(['ls-remote', `file://${WORK}`], { env: ENV });
208 if (!/not allowed|not supported/i.test(err)) {
209 throw new Error(`file:: was not refused:\n${err}`);
210 }
211});
212
213// A pre-push hook is a script in the repository, and it would run with the credential in its
214// environment. The hooks path is pointed inside `.daimond`, which every fence denies.
215check('hooks are pointed at the one directory every fence denies', () => {
216 const p = git(['config', 'core.hooksPath'], { env: ENV });
217 if (!p.endsWith('/.daimond/no-hooks')) {
218 throw new Error(`hooks are not disabled for a push: ${p}`);
219 }
220 mkdirSync(join(WORK, '.git', 'hooks'), { recursive: true });
221 writeFileSync(join(WORK, '.git', 'hooks', 'pre-push'), '#!/bin/sh\necho PWNED\nexit 1\n',
222 { mode: 0o755 });
223 // git resolves the hook through core.hooksPath, so the repository's own is not found.
224 const found = git(['rev-parse', '--git-path', 'hooks/pre-push'], { env: ENV });
225 if (found.includes('.git/hooks/')) {
226 throw new Error(`the repository's own hook is still the one git would run: ${found}`);
227 }
228});
229
230// Without a terminal git would sit on a password prompt until the timeout rather than fail.
231check('a failed authentication fails rather than waiting for a prompt', () => {
232 if (ENV.GIT_TERMINAL_PROMPT !== '0') {
233 throw new Error('GIT_TERMINAL_PROMPT is not disabled');
234 }
235});
236
237// And the header itself, against the shell's own base64 rather than against the encoder that
238// produced it.
239check('the Authorization header is what base64 says it is', () => {
240 const want = execFileSync('base64', { input: `${USER}:${TOKEN}`, encoding: 'utf8' }).trim();
241 const got = git(['config', '--get-urlmatch', 'http', `https://${HOST}/o/r`], { env: ENV });
242 if (!got.includes(want)) {
243 throw new Error(`the header is not base64 of '${USER}:<token>':\n${got}`);
244 }
245});
246
247rmSync(WORK, { recursive: true, force: true });
248console.log(failed === 0 ? '\nall checks passed' : `\n${failed} check(s) FAILED`);
249process.exit(failed === 0 ? 0 : 1);