mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-31 16:21:30 +00:00
* feat(secure-cli): Phase 1 schema + storage delta (issue #82) Adds `adapter_name` column to secure_cli_binaries and `credential_type` + `host_scope` columns to secure_cli_user_credentials. LookupByBinary LEFT JOIN now projects AdapterName, UserCredentialType, and UserHostScope. Extends SecureCLIStore with SetUserCredentialsTyped(ctx, binaryID, userID, env, credType, hostScope); legacy SetUserCredentials delegates to typed variant with nil/nil for backward compat. PG migration 73 + RequiredSchemaVersion bumped to 73. SQLite incremental migrations (versions 39–41) + SchemaVersion 42. 7 SQLite + 6 PG integration tests covering schema, round-trip, NULL legacy, LookupByBinary projection. Fixes #82 * feat(secure-cli): Phase 2 CredentialAdapter framework + passthrough (issue #82) Adds CredentialAdapter interface + Injection{ArgvPrefix,Env,Cleanup,ScrubValues} struct. Registry resolves by name; falls back to passthrough for empty/unknown. passthroughAdapter is default no-op — all existing presets (gh/aws/gcloud/kubectl/terraform/gws) behave bit-for-bit identically. Per-request WithScrubBag(ctx) + AddScrubValuesCtx + ScrubCredentialsCtx for multi-tenant secret isolation (replaces package-global slice). Hook in executeCredentialed between env merge and exec: resolve adapter by bin.AdapterName, reject non-passthrough in sandbox, call Prepare, splice ArgvPrefix, merge Env, defer Cleanup, register ScrubValues. Audit log security.system_env_injection records adapter name + env key names + argv_prefix_len + sha256(host_scope) — NEVER values. CLIPreset.AdapterName field added; empty default for legacy presets. 12 unit tests covering passthrough no-op, registry fallback + nil safety, Injection shape, hashHostScope determinism, sortedKeys, scrub bag per-request isolation + concurrent + short-value guard. Fixes #82 * feat(secure-cli): Phase 2b extensibility helpers + psql stub adapter (issue #82) Proves CredentialAdapter framework generalizes beyond git. Adds materializeEphemeral(ctx, content, prefix) shared helper — 0600 tmpfile + idempotent atomic.Bool cleanup latch; memfd intentionally rejected (resolves "self" against child process). Adds psqlAdapter consuming framework end-to-end via PGPASSFILE pattern; libpq-spec .pgpass escaping for `:` and `\`. Registers psql preset with AdapterName: "psql" (production UI for typed creds lands in v2). Interface validation gate passes: Injection shape unchanged, hook is psql-agnostic, no special branch needed. Tests: ephemeral write/cleanup/concurrent/zero-content; psql routing/content/escaping/error paths/registration. Fixes #82 * feat(secure-cli): git adapter PAT + SSH implementation (issue #82) Phase 3 — PAT path - gitAdapter PAT branch via GIT_CONFIG_COUNT/KEY_0/VALUE_0 env (git 2.31+) so the token never lands on argv, .git/config, or remote URL - Host-scope enforcement with IDN normalization (golang.org/x/net/idna) and embedded-userinfo rejection in URL parsing - CVE-2018-17456 mitigation: resolve remote URLs via `git config --get` not `git remote get-url` to dodge ext::sh protocol handler injection - Case-insensitive DenyArgs blocking `-c http.`, `-c credential.`, `-c core.sshcommand`, `config --global/--system`, `credential-helper`, bare `daemon` - New WithExecCwd / ExecCwdFromContext context helpers — fixes a latent design gap where the adapter's pre-flight `git config --get` ran in goclaw's daemon CWD instead of the agent's repo - 14 unit tests covering subcommand routing, host normalization, scp-form parsing, userinfo rejection, CRLF token rejection, CVE-2018-17456 regression, DenyArgs preset coverage - 3 integration tests against a local TLS git-http-backend server proving end-to-end clone + fetch + host-mismatch rejection with zero token leakage into the cloned .git/config Phase 4 — SSH path - gitAdapter ssh_key branch materializes per-call 0600 tmpfile via the Phase 2b materializeEphemeral helper, injects GIT_SSH_COMMAND with -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new, idempotent cleanup - ValidateSSHKey using golang.org/x/crypto/ssh rejects passphrase-protected keys via ErrSSHKeyPassphraseUnsupported sentinel - 8 unit tests covering passphrase rejection, env shape, cleanup lifecycle, host-mismatch reuse, malformed-blob rejection - 3 integration tests proving tmpfile 0600 lifecycle, env propagation to child process, cleanup-on-exec-failure, no-orphan-on-rejection - 2 new i18n keys (en/vi/zh) for SSH-passphrase and SSH-key-invalid Verification: go vet clean, go build ./... clean, go build -tags sqliteonly ./... clean, go test -race ./internal/tools/ pass, go test -tags integration ./tests/integration/ -run TestGitAdapter pass. Refs #82 * feat(secure-cli): UI presets + typed credential HTTP path + i18n (issue #82) Phase 5. Typed PUT envelope with {error:{code,message}, error_key} for field-level errors. CliCredentialGitFields React component (PAT/SSH picker, host_scope input, CRLF→LF normalization, masked-edit). 17 i18n keys × 3 locales (backend + frontend). i18n parity test. * feat(secure-cli): audit log schema + adapter framework docs (issue #82) Phase 6. emitSystemEnvInjectionAudit helper centralizes security.system_env_injection slog with host_scope_hash (SHA-256 8 hex, plaintext hostname omitted for PII safety). Audit shape pinned by TestEmitSystemEnvInjectionAudit_*. New docs: git-credential-adapter.md (user guide), credential-adapter-playbook.md (R1 implementer guide w/ kubectl/docker/npm/aws/psql worked mappings). 09-security.md § 14 trust-boundary diagram + SSH TOFU + SIGKILL caveats. 03-tools-system.md § 8a. Changelog entry. * docs(journal): issue #82 CLI credential adapters shipped Retrospective covering 6 commits across phases 1-6: framework, git PAT/SSH, psql stub, UI/i18n, audit log schema, docs. Notes memfd-drop rationale, TOFU/SIGKILL caveats, sentinel-length lesson from audit-shape tests. * fix(secure-cli): honor per-request scrub bag on success+failure paths (#82) Sandbox and host exec paths called the non-Ctx ScrubCredentials, so adapter ScrubValues registered into the per-request bag during Prepare (e.g. GitLab glpat-, Bitbucket app-passwords, Azure DevOps PATs, Gitea tokens, SSH key tmpfile paths) were ignored on stdout/stderr returned to the agent. Only the package-global regex pass ran — covering ghp_ but nothing else. Switch all four exec/sandbox call sites to ScrubCredentialsCtx so the bag is consulted. Also scrub the slog adapter_cleanup_failed line — os.Remove errors embed the full tmpfile path. Add 5 regression tests that pin success path, failure path, negative control (proves the bag is what catches the sentinel), classic-PAT sanity, and timeout path no-leak. Locks AC6 against non-GitHub PAT providers. * fix(secure-cli): address review-pr #89 findings - ui/web: SSH key Textarea was 'text-xs' on all viewports, triggering iOS Safari auto-zoom on focus. Switch to 'text-base md:text-xs' so mobile renders 16px (no zoom) while desktop keeps compact mono font. - psql adapter: add on-disk pgpass tmpfile path to ScrubValues. psql echoes 'could not open password file "<path>"' on IO errors, so the path needs scrubbing alongside the password. Mirrors the git SSH adapter pattern. Update test assertion accordingly. - psql adapter: replace literal 'nil' context with 'context.TODO()' to silence staticcheck SA1012.
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"golang.org/x/net/idna"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// gitAdapter implements the CredentialAdapter contract for the `git` binary.
|
|
//
|
|
// PAT path (this file): host-scoped HTTP extraheader injected via
|
|
// GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0 env vars (git 2.31+).
|
|
// Keeps the token out of /proc/<pid>/cmdline and `ps` output — the typical
|
|
// `-c http.extraHeader=...` form would land the bearer token directly in argv
|
|
// where any process listing leaks it.
|
|
//
|
|
// SSH path: Phase 4 extends the same Prepare switch.
|
|
type gitAdapter struct{}
|
|
|
|
func (gitAdapter) Name() string { return "git" }
|
|
|
|
// ShouldInject returns true only for subcommands that talk to a remote.
|
|
// Status/log/diff/commit do not need credentials, and asking the adapter for
|
|
// them would waste a `git config --get` sub-exec and emit noisy audit events.
|
|
func (gitAdapter) ShouldInject(argv []string) bool {
|
|
if len(argv) == 0 {
|
|
return false
|
|
}
|
|
switch argv[0] {
|
|
case "clone", "fetch", "pull", "push", "submodule":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var (
|
|
errEmptyToken = errors.New("git adapter: token is empty")
|
|
errTokenTooLong = errors.New("git adapter: token exceeds 4 KiB")
|
|
errTokenControlChar = errors.New("git adapter: token contains control character (CR/LF/NUL)")
|
|
errEmptyHost = errors.New("git adapter: host is empty")
|
|
errEmbeddedUserinfo = errors.New("git adapter: URL has embedded userinfo (ambiguous; rejected)")
|
|
errURLMissingHost = errors.New("git adapter: URL has no host")
|
|
errURLUnsupportedForm = errors.New("git adapter: URL form not recognized")
|
|
errCloneURLMissing = errors.New("git adapter: clone command has no URL argument")
|
|
errUnknownSubcommand = errors.New("git adapter: unknown subcommand")
|
|
errCredentialTypeBlank = errors.New("git adapter: credential_type required for git adapter routing")
|
|
)
|
|
|
|
// errCredentialHostMismatch is returned when the matched credential's host
|
|
// scope does not equal the target host extracted from argv (or remote URL).
|
|
// Carries both hosts in fields so callers can format an i18n message without
|
|
// re-parsing. Error.Error() intentionally omits any token-shaped data.
|
|
type errCredentialHostMismatch struct {
|
|
credHost string
|
|
targetHost string
|
|
}
|
|
|
|
func (e *errCredentialHostMismatch) Error() string {
|
|
return fmt.Sprintf("git adapter: credential host %q does not match target host %q", e.credHost, e.targetHost)
|
|
}
|
|
|
|
func (gitAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary, cred *store.SecureCLIUserCredential, argv []string) (*Injection, error) {
|
|
if cred == nil {
|
|
return &Injection{}, nil
|
|
}
|
|
typ := ""
|
|
if cred.CredentialType != nil {
|
|
typ = *cred.CredentialType
|
|
}
|
|
// Legacy env-vars rows: behave exactly like passthrough so existing operators
|
|
// who wired `git` via env-paste before the adapter existed keep working.
|
|
if typ == "" || typ == "env" {
|
|
return &Injection{}, nil
|
|
}
|
|
|
|
// cwd from ctx so `git config --get remote.X.url` runs inside the repo
|
|
// the caller is about to operate on. Empty when caller did not set it
|
|
// (e.g. clone, where the URL is in argv and cwd is irrelevant).
|
|
host, err := resolveTargetHost(ctx, argv, ExecCwdFromContext(ctx))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve target host: %w", err)
|
|
}
|
|
credHostRaw := ""
|
|
if cred.HostScope != nil {
|
|
credHostRaw = *cred.HostScope
|
|
}
|
|
credHost, err := normalizeHost(credHostRaw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize credential host: %w", err)
|
|
}
|
|
targetHost, err := normalizeHost(host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("normalize target host: %w", err)
|
|
}
|
|
if credHost != targetHost {
|
|
return nil, &errCredentialHostMismatch{credHost: credHost, targetHost: targetHost}
|
|
}
|
|
|
|
switch typ {
|
|
case "pat":
|
|
token, err := decodePATToken(cred.EncryptedEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateTokenShape(token); err != nil {
|
|
return nil, err
|
|
}
|
|
// GIT_CONFIG_COUNT env approach (git 2.31+) — same effect as
|
|
// `-c http.https://host/.extraheader=Authorization: Bearer <tok>` but
|
|
// the token stays out of argv. Single-entry header keeps host-scoping
|
|
// strict; the matching url prefix ensures git skips its credential
|
|
// helpers for this URL automatically.
|
|
configKey := fmt.Sprintf("http.https://%s/.extraheader", targetHost)
|
|
configVal := fmt.Sprintf("Authorization: Bearer %s", token)
|
|
return &Injection{
|
|
Env: map[string]string{
|
|
"GIT_CONFIG_COUNT": "1",
|
|
"GIT_CONFIG_KEY_0": configKey,
|
|
"GIT_CONFIG_VALUE_0": configVal,
|
|
},
|
|
ScrubValues: []string{token},
|
|
}, nil
|
|
case "ssh_key":
|
|
keyPEM, err := decodeSSHKeyBlob(cred.EncryptedEnv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Defense-in-depth: ValidateSSHKey is called at save; running it again
|
|
// here catches keys that bypassed validation (e.g. raw DB write or a
|
|
// future store mutation that skips the handler path).
|
|
if err := ValidateSSHKey(keyPEM); err != nil {
|
|
return nil, err
|
|
}
|
|
keyPath, cleanup, err := materializeEphemeral(ctx, keyPEM, "gitkey")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// -o flags pinned for safety:
|
|
// IdentitiesOnly=yes — ssh must not try other keys in ~/.ssh
|
|
// BatchMode=yes — never prompt; fail fast in agent context
|
|
// StrictHostKeyChecking=accept-new — first contact TOFU; known hosts
|
|
// still enforced after
|
|
// UserKnownHostsFile=~/.ssh/known_hosts is left at default so
|
|
// operators can pre-seed pinned host keys (documented in Phase 6).
|
|
sshCmd := fmt.Sprintf(
|
|
"ssh -i %s -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
|
|
keyPath,
|
|
)
|
|
return &Injection{
|
|
Env: map[string]string{"GIT_SSH_COMMAND": sshCmd},
|
|
Cleanup: cleanup,
|
|
ScrubValues: []string{keyPath},
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("git adapter: unsupported credential_type %q", typ)
|
|
}
|
|
}
|
|
|
|
// decodeSSHKeyBlob extracts the private-key PEM from the credential blob.
|
|
// v1 wire shape: `{"key":"-----BEGIN OPENSSH PRIVATE KEY-----\n..."}`.
|
|
// No legacy fallback — SSH support is new in Phase 4, so any existing
|
|
// row with credential_type='ssh_key' was written by the new wire.
|
|
func decodeSSHKeyBlob(blob []byte) ([]byte, error) {
|
|
if len(blob) == 0 {
|
|
return nil, errors.New("git adapter: empty ssh key blob")
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(blob, &m); err != nil {
|
|
return nil, fmt.Errorf("decode ssh key blob: %w", err)
|
|
}
|
|
if k, ok := m["key"]; ok && k != "" {
|
|
return []byte(k), nil
|
|
}
|
|
return nil, errors.New("git adapter: ssh key blob missing 'key' field")
|
|
}
|
|
|
|
// decodePATToken extracts the token from the credential blob. v1 wire shape
|
|
// is `{"token": "..."}`. Falls back to legacy env-style `{"GIT_TOKEN": "..."}`
|
|
// only when the typed key is missing, so operators migrating from env-paste
|
|
// rows do not need to re-enter their PAT.
|
|
func decodePATToken(blob []byte) (string, error) {
|
|
if len(blob) == 0 {
|
|
return "", errEmptyToken
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(blob, &m); err != nil {
|
|
return "", fmt.Errorf("decode pat blob: %w", err)
|
|
}
|
|
if tok, ok := m["token"]; ok {
|
|
return tok, nil
|
|
}
|
|
if tok, ok := m["GIT_TOKEN"]; ok {
|
|
return tok, nil
|
|
}
|
|
return "", errEmptyToken
|
|
}
|
|
|
|
// validateTokenShape rejects empty, oversized, and control-char tokens. The
|
|
// CR/LF check defends against header injection if the UI validator ever
|
|
// regresses — a token like "ghp_x\r\nX-Evil: y" would otherwise smuggle a
|
|
// second HTTP header through GIT_CONFIG_VALUE_0.
|
|
func validateTokenShape(tok string) error {
|
|
if tok == "" {
|
|
return errEmptyToken
|
|
}
|
|
if len(tok) > 4096 {
|
|
return errTokenTooLong
|
|
}
|
|
for _, r := range tok {
|
|
if r < 0x20 || r == 0x7f {
|
|
return errTokenControlChar
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeHost canonicalizes a hostname for apples-to-apples comparison:
|
|
// trim whitespace + trailing dot, lowercase, idna.Lookup.ToASCII for IDN/
|
|
// punycode equivalence. Preserves explicit ports (`:8443`) verbatim.
|
|
func normalizeHost(h string) (string, error) {
|
|
h = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(h)), ".")
|
|
if h == "" {
|
|
return "", errEmptyHost
|
|
}
|
|
hostPart, port, hasPort := splitHostPortOptional(h)
|
|
ascii, err := idna.Lookup.ToASCII(hostPart)
|
|
if err != nil {
|
|
return "", fmt.Errorf("idna: %w", err)
|
|
}
|
|
if hasPort {
|
|
return ascii + ":" + port, nil
|
|
}
|
|
return ascii, nil
|
|
}
|
|
|
|
// splitHostPortOptional splits "host:port" when port is all digits; otherwise
|
|
// returns host unchanged. Avoids net.SplitHostPort because that errors on
|
|
// portless inputs and on IPv6 forms without brackets, which we don't accept.
|
|
func splitHostPortOptional(h string) (host, port string, ok bool) {
|
|
idx := strings.LastIndex(h, ":")
|
|
if idx < 0 {
|
|
return h, "", false
|
|
}
|
|
port = h[idx+1:]
|
|
if port == "" {
|
|
return h, "", false
|
|
}
|
|
for _, r := range port {
|
|
if r < '0' || r > '9' {
|
|
return h, "", false
|
|
}
|
|
}
|
|
return h[:idx], port, true
|
|
}
|
|
|
|
// resolveTargetHost finds the host this git invocation will contact. For
|
|
// clone, the URL is in argv. For fetch/pull/push/submodule, we sub-exec a
|
|
// hardened `git config --get remote.<name>.url` to read it from .git/config.
|
|
//
|
|
// cwd is the working directory for the sub-exec. Empty means current process
|
|
// dir (used by adapter Prepare in production where the exec hasn't chdir'd
|
|
// yet — git -C . is the implicit default).
|
|
func resolveTargetHost(ctx context.Context, argv []string, cwd string) (string, error) {
|
|
if len(argv) == 0 {
|
|
return "", errUnknownSubcommand
|
|
}
|
|
switch argv[0] {
|
|
case "clone":
|
|
u, err := firstNonFlagArg(argv[1:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return parseHostFromGitURL(u)
|
|
case "fetch", "pull":
|
|
remote := pickRemoteFromArgv(argv[1:], "origin")
|
|
return remoteURLViaConfigGet(ctx, cwd, remote)
|
|
case "push":
|
|
remote := pickPushRemoteFromArgv(argv[1:], "origin")
|
|
return remoteURLViaConfigGet(ctx, cwd, remote)
|
|
case "submodule":
|
|
// `git submodule update` uses the parent repo's origin URL as the
|
|
// reachability base for relative submodule paths, and the submodule
|
|
// fetch reuses the parent's auth header when the host matches.
|
|
return remoteURLViaConfigGet(ctx, cwd, "origin")
|
|
}
|
|
return "", errUnknownSubcommand
|
|
}
|
|
|
|
// firstNonFlagArg returns the first argv element that does not start with `-`.
|
|
// Used to find the clone URL past option flags like --depth, --branch, etc.
|
|
func firstNonFlagArg(args []string) (string, error) {
|
|
skipValueFor := map[string]bool{
|
|
// Long options that take a separate value as the next arg.
|
|
"--branch": true, "--depth": true, "--origin": true, "--config": true,
|
|
"--reference": true, "--reference-if-able": true, "--separate-git-dir": true,
|
|
"--template": true, "--upload-pack": true, "--jobs": true, "--shallow-since": true,
|
|
"--shallow-exclude": true, "--filter": true, "--server-option": true,
|
|
"-b": true, "-o": true, "-c": true, "-j": true, "-u": true,
|
|
}
|
|
skipNext := false
|
|
for _, a := range args {
|
|
if skipNext {
|
|
skipNext = false
|
|
continue
|
|
}
|
|
if strings.HasPrefix(a, "-") {
|
|
// `--depth=1` style — value embedded; no skip.
|
|
if strings.Contains(a, "=") {
|
|
continue
|
|
}
|
|
if skipValueFor[a] {
|
|
skipNext = true
|
|
}
|
|
continue
|
|
}
|
|
return a, nil
|
|
}
|
|
return "", errCloneURLMissing
|
|
}
|
|
|
|
// pickRemoteFromArgv finds the explicit remote name in `git fetch/pull` argv,
|
|
// or returns `fallback` when none is given. Flags are skipped.
|
|
func pickRemoteFromArgv(args []string, fallback string) string {
|
|
for _, a := range args {
|
|
if strings.HasPrefix(a, "-") {
|
|
continue
|
|
}
|
|
return a
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// pickPushRemoteFromArgv handles `git push`'s extra `--repo=<remote>` shape.
|
|
func pickPushRemoteFromArgv(args []string, fallback string) string {
|
|
for _, a := range args {
|
|
if strings.HasPrefix(a, "--repo=") {
|
|
return strings.TrimPrefix(a, "--repo=")
|
|
}
|
|
}
|
|
return pickRemoteFromArgv(args, fallback)
|
|
}
|
|
|
|
// remoteURLViaConfigGet runs a hardened `git config --get remote.<name>.url`
|
|
// sub-exec to read the remote URL from .git/config without triggering any
|
|
// url-rewriting (insteadOf), credential helpers, or protocol-handler exec.
|
|
//
|
|
// We use `git config --get` instead of `git remote get-url` because the
|
|
// latter routes through git_handle_repo, which is the CVE-2018-17456 attack
|
|
// surface (malicious `[remote] url = ext::sh -c <evil>` in .git/config would
|
|
// execute arbitrary shell). The CVE has been patched in modern git, but the
|
|
// defense-in-depth pattern keeps this immune to any future regression.
|
|
//
|
|
// GIT_ALLOW_PROTOCOL allowlists what git considers valid; GIT_TERMINAL_PROMPT=0
|
|
// prevents interactive auth prompts; GIT_CONFIG_NOSYSTEM=1 ignores /etc/gitconfig
|
|
// so a host-wide rewrite cannot influence the lookup.
|
|
func remoteURLViaConfigGet(ctx context.Context, cwd, remote string) (string, error) {
|
|
args := []string{"-C", cwd, "config", "--get", "remote." + remote + ".url"}
|
|
if cwd == "" {
|
|
args = []string{"config", "--get", "remote." + remote + ".url"}
|
|
}
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_ALLOW_PROTOCOL=https:http:ssh:git",
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
"GIT_CONFIG_NOSYSTEM=1",
|
|
)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("read remote url for %q: %w", remote, err)
|
|
}
|
|
return parseHostFromGitURL(strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
// parseHostFromGitURL extracts the host[:port] from a git remote URL.
|
|
// Three forms are accepted:
|
|
//
|
|
// https://host[:port]/... (HTTPS — embedded userinfo REJECTED)
|
|
// git@host:owner/repo.git (scp-form SSH — bare user, no userinfo collision)
|
|
// ssh://git@host[:port]/... (full SSH — user MUST be "git" or absent)
|
|
//
|
|
// Embedded `https://user@host/...` is rejected as ambiguous: git treats `host`
|
|
// as the target but operators reading the URL often think `user` is the host.
|
|
// An attacker who controls `host` could trick a credential-host check into
|
|
// matching the wrong side. Refuse rather than guess.
|
|
func parseHostFromGitURL(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", errURLMissingHost
|
|
}
|
|
|
|
// scp-form: `user@host:path`. Match BEFORE generic URL parsing because
|
|
// `git@github.com:owner/repo` looks like `git@github.com:owner` host:port
|
|
// to url.Parse, mis-bucketing the path into the port.
|
|
if !strings.Contains(raw, "://") && strings.Contains(raw, ":") && strings.Contains(raw, "@") {
|
|
at := strings.Index(raw, "@")
|
|
colon := strings.Index(raw[at:], ":")
|
|
if colon > 0 {
|
|
host := raw[at+1 : at+colon]
|
|
if host == "" {
|
|
return "", errURLMissingHost
|
|
}
|
|
return host, nil
|
|
}
|
|
}
|
|
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse url: %w", err)
|
|
}
|
|
if u.Scheme == "" || u.Host == "" {
|
|
return "", errURLUnsupportedForm
|
|
}
|
|
// Reject embedded userinfo for HTTPS/HTTP.
|
|
// Exception: ssh://git@host is the conventional and unambiguous SSH form;
|
|
// we allow it only when user is empty or exactly "git" (no password).
|
|
if u.User != nil {
|
|
switch u.Scheme {
|
|
case "https", "http":
|
|
return "", errEmbeddedUserinfo
|
|
case "ssh":
|
|
if _, hasPass := u.User.Password(); hasPass {
|
|
return "", errEmbeddedUserinfo
|
|
}
|
|
if name := u.User.Username(); name != "" && name != "git" {
|
|
return "", errEmbeddedUserinfo
|
|
}
|
|
default:
|
|
return "", errEmbeddedUserinfo
|
|
}
|
|
}
|
|
return u.Host, nil
|
|
}
|
|
|
|
func init() {
|
|
RegisterAdapter(gitAdapter{})
|
|
}
|