diff --git a/scripts/git-credential-forge.py b/scripts/git-credential-forge.py index f77b7fe..2c90b3a 100755 --- a/scripts/git-credential-forge.py +++ b/scripts/git-credential-forge.py @@ -12,11 +12,12 @@ On every ``get`` the helper: 1. Reads the auth file (same path the gateway uses: ``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default, or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point - at). If the file is missing, the helper passes git's input through - unchanged so the normal prompt chain continues. + at). If the file is missing, the helper emits no output so the next + helper in Git's chain can answer without receiving echoed fields from + this helper. 2. Checks that the git request host matches the host recorded in - ``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, passes - through. This prevents OAuth token disclosure to unrelated + ``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, emits no + output. This prevents OAuth token disclosure to unrelated hosts even if git mis-scopes its lookup. 3. If ``gitea_access_token`` is live, emits ``username=`` and ``password=``. @@ -29,7 +30,7 @@ Security notes -------------- * The helper never writes to stdout except the credential key=value block. Logs go to stderr. -* On OAuth refresh failure the helper exits **non-zero** rather than silently +* On OAuth refresh failure the helper emits no credentials rather than returning stale credentials. """ from __future__ import annotations @@ -82,6 +83,10 @@ def emit(fields: dict[str, str]) -> None: sys.stdout.write(f"{key}={value}\n") +def emit_no_credentials() -> None: + return + + # -------------------------------------------------------------------- # Host matching # -------------------------------------------------------------------- @@ -150,10 +155,10 @@ def _request_matches( def cmd_get(fields: dict[str, str]) -> int: configured = _configured_host() if configured is None: - emit(fields) # pass-through: helper scope is unknown + emit_no_credentials() return 0 if not _request_matches(fields, configured): - emit(fields) # pass-through: request targets a different host + emit_no_credentials() return 0 state = forge_auth.AuthFile.read(forge_auth.auth_store_path()) @@ -172,7 +177,7 @@ def cmd_get(fields: dict[str, str]) -> int: "FSDGG_CLI_CLIENT_ID are not set in the environment; " "cannot refresh. Run 'just login' to re-authenticate." ) - emit(fields) + emit_no_credentials() return 0 try: refreshed = forge_auth.run_refresh(config, must_refresh=True) @@ -181,7 +186,7 @@ def cmd_get(fields: dict[str, str]) -> int: f"token refresh failed: {exc}. " f"Run 'just login' to re-authenticate." ) - emit(fields) + emit_no_credentials() return 0 _emit_credentials(fields, refreshed) return 0 @@ -190,7 +195,7 @@ def cmd_get(fields: dict[str, str]) -> int: def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None: token = state.gitea_access_token if not token: - emit(fields) + emit_no_credentials() return out = dict(fields) out["username"] = state.username or fields.get("username") or "oauth" diff --git a/tests/test_git_credential_forge.py b/tests/test_git_credential_forge.py index b77628c..1b6fde2 100755 --- a/tests/test_git_credential_forge.py +++ b/tests/test_git_credential_forge.py @@ -177,16 +177,16 @@ class CmdGetTests(unittest.TestCase): self.assertEqual(parsed["password"], "LIVETOKEN") self.assertEqual(parsed["host"], "g.example:8443") - def test_no_store_passes_through(self) -> None: + def test_no_store_emits_no_credentials(self) -> None: rc, out, _ = self._run( store_payload=None, stdin_text="protocol=https\nhost=g.example:8443\n\n", ) self.assertEqual(rc, 0) + self.assertEqual(out, "") self.assertNotIn("password=", out) - self.assertIn("host=g.example:8443", out) - def test_non_matching_host_passes_through(self) -> None: + def test_non_matching_host_emits_no_credentials(self) -> None: payload = { "username": "alice", "gitea_access_token": "LIVETOKEN", @@ -198,10 +198,10 @@ class CmdGetTests(unittest.TestCase): stdin_text="protocol=https\nhost=github.com\n\n", ) self.assertEqual(rc, 0) + self.assertEqual(out, "") self.assertNotIn("password=", out) - self.assertIn("host=github.com", out) - def test_match_but_no_token_passes_through(self) -> None: + def test_match_but_no_token_emits_no_credentials(self) -> None: payload = { "username": "alice", "gitea_access_token": "", @@ -212,6 +212,7 @@ class CmdGetTests(unittest.TestCase): stdin_text="protocol=https\nhost=g.example:8443\n\n", ) self.assertEqual(rc, 0) + self.assertEqual(out, "") self.assertNotIn("password=", out) # --- expired token + refresh ------------------------------------- @@ -263,6 +264,7 @@ class CmdGetTests(unittest.TestCase): stdin_text="protocol=https\nhost=g.example:8443\n\n", ) self.assertEqual(rc, 0) + self.assertEqual(out, "") self.assertNotIn("password=", out) self.assertIn("token refresh failed", err) self.assertIn("just login", err) diff --git a/tests/test_setup_args.sh b/tests/test_setup_args.sh index 2cbfed4..a73bb58 100755 --- a/tests/test_setup_args.sh +++ b/tests/test_setup_args.sh @@ -24,13 +24,15 @@ assert() { assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh" -help_out="$(bash "$root/scripts/setup.sh" --help 2>&1)" +help_file="$(mktemp)" +bash "$root/scripts/setup.sh" --help >"$help_file" 2>&1 assert '--help prints the Usage header' \ - bash -c "printf '%s' \"$help_out\" | grep -q '^Usage: just setup'" + grep -q '^Usage: just setup' "$help_file" assert '--help documents --headless' \ - bash -c "printf '%s' \"$help_out\" | grep -q -- '--headless'" + grep -q -- '--headless' "$help_file" assert '--help documents FORGE_SETUP_YES' \ - bash -c "printf '%s' \"$help_out\" | grep -q 'FORGE_SETUP_YES'" + grep -q 'FORGE_SETUP_YES' "$help_file" +rm -f "$help_file" set +e bash "$root/scripts/setup.sh" --not-a-flag >/dev/null 2>"$here/.bad.err"