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 | |
| 16 | import { execFileSync } from 'node:child_process'; |
| 17 | import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; |
| 18 | import { homedir } from 'node:os'; |
| 19 | import { join } from 'node:path'; |
| 20 | |
| 21 | const WORK = join(homedir(), '.cache', 'daimond-verify', 'gitpush'); |
| 22 | const HOST = 'github.com'; |
| 23 | const TOKEN = 'NOT-A-REAL-TOKEN'; // allowlist secret |
| 24 | const 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. |
| 28 | function 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 | |
| 44 | let failed = 0; |
| 45 | function 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 | |
| 55 | function 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 | |
| 65 | function 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 | |
| 74 | rmSync(WORK, { recursive: true, force: true }); |
| 75 | mkdirSync(WORK, { recursive: true }); |
| 76 | git(['init', '-q', '.']); |
| 77 | git(['remote', 'add', 'origin', `git@${HOST}:oxedyne-com/fe2o3.git`]); |
| 78 | const ENV = pushEnv(WORK); |
| 79 | |
| 80 | console.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. |
| 84 | check('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. |
| 93 | check('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 | |
| 100 | check('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. |
| 110 | check('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. |
| 124 | check('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. |
| 147 | check('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. |
| 182 | check('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. |
| 196 | check('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 | |
| 206 | check('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. |
| 215 | check('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. |
| 231 | check('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. |
| 239 | check('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 | |
| 247 | rmSync(WORK, { recursive: true, force: true }); |
| 248 | console.log(failed === 0 ? '\nall checks passed' : `\n${failed} check(s) FAILED`); |
| 249 | process.exit(failed === 0 ? 0 : 1); |