Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions hermes_cli/github_app_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,30 @@ def _default_home() -> Path:
return Path(os.environ.get("HOME", "/opt/data/home")).parent


# Hosts this helper will answer for. Git invokes credential helpers for EVERY
# https remote, so an unscoped helper that ignored the request's host would
# hand the installation token to any URL a repo (or a malicious submodule/
# redirect) points at. The boot-written gitconfig already scopes the helper to
# https://github.com; this allowlist enforces the same boundary inside the
# helper itself, so a mis-scoped or globally-wired config can't leak the token.
ALLOWED_HOSTS = frozenset({"github.com", "gist.github.com"})


def _read_credential_request() -> dict:
"""Parse git's credential description block (key=value lines) from stdin."""
request: dict = {}
try:
if sys.stdin is None or sys.stdin.isatty():
return request
for line in sys.stdin.read().splitlines():
key, sep, value = line.partition("=")
if sep:
request[key.strip()] = value.strip()
except OSError:
pass
return request


def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(prog="hermes_cli.github_app_token")
sub = parser.add_subparsers(dest="cmd", required=True)
Expand All @@ -253,6 +277,17 @@ def main(argv: Optional[list] = None) -> int:
if args.cmd in ("get-credential", "get"):
if args.op not in (None, "get"):
return 0 # store/erase are no-ops for a mint-on-demand helper
# Only answer for GitHub over https. For anything else, emit nothing
# and exit 0 so git falls through to other helpers / prompts — never
# send the token toward a host we don't recognize. An empty request
# (no stdin, e.g. a manual smoke-test invocation) still answers.
request = _read_credential_request()
host = request.get("host", "")
protocol = request.get("protocol", "")
if host and host not in ALLOWED_HOSTS:
return 0
if protocol and protocol != "https":
return 0
home = args.home or _default_home()
token = get_installation_token(home)
if not token:
Expand Down
52 changes: 52 additions & 0 deletions tests/hermes_cli/test_github_app_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,55 @@ def test_unknown_subcommand_names_valid_choices(capsys):
gat.main(["mint"])
err = capsys.readouterr().err
assert "get-credential" in err


# ---------------------------------------------------------------------------
# get-credential host allowlist — never answer for a host we don't recognize
# ---------------------------------------------------------------------------


def _pipe_stdin(monkeypatch, text: str) -> None:
import io

monkeypatch.setattr(gat.sys, "stdin", io.StringIO(text))


def test_get_credential_refuses_foreign_host(app_env, monkeypatch, capsys):
def boom(home, force=False):
raise AssertionError("must not mint for a non-GitHub host")

monkeypatch.setattr(gat, "get_installation_token", boom)
_pipe_stdin(monkeypatch, "protocol=https\nhost=evil.example.com\n")
rc = gat.main(["get-credential", "--home", str(app_env)])
assert rc == 0 # silent fall-through, git tries the next helper
assert capsys.readouterr().out == ""


def test_get_credential_refuses_non_https(app_env, monkeypatch, capsys):
def boom(home, force=False):
raise AssertionError("must not mint for a non-https request")

monkeypatch.setattr(gat, "get_installation_token", boom)
_pipe_stdin(monkeypatch, "protocol=http\nhost=github.com\n")
rc = gat.main(["get-credential", "--home", str(app_env)])
assert rc == 0
assert capsys.readouterr().out == ""


@pytest.mark.parametrize("host", ["github.com", "gist.github.com"])
def test_get_credential_answers_for_allowed_hosts(app_env, monkeypatch, capsys, host):
monkeypatch.setattr(gat, "get_installation_token", lambda home, force=False: "ghs_ok")
_pipe_stdin(monkeypatch, f"protocol=https\nhost={host}\n")
rc = gat.main(["get-credential", "--home", str(app_env)])
out = capsys.readouterr().out
assert rc == 0
assert "password=ghs_ok\n" in out


def test_get_credential_answers_on_empty_request(app_env, monkeypatch, capsys):
"""No stdin description (manual smoke test / boot-style call) still answers."""
monkeypatch.setattr(gat, "get_installation_token", lambda home, force=False: "ghs_ok")
_pipe_stdin(monkeypatch, "")
rc = gat.main(["get-credential", "--home", str(app_env)])
assert rc == 0
assert "password=ghs_ok\n" in capsys.readouterr().out
Loading