Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 08:09
· 77 commits to main since this release
d4e1cfa

Security

  • Bumped golang.org/x/crypto to v0.54.0, closing 13 Dependabot alerts (7 critical, 3 high, 3 medium), all of them in its SSH implementation. Raised as an indirect dependency via minimal version selection, since go-git still asks for v0.51.0; lifts golang.org/x/net to v0.56.0 and golang.org/x/sys to v0.47.0 (#104)

  • Bumped go-git to v6.0.0-alpha.5, clearing two advisories govulncheck reported as reachable from git-sync's own call graph: GO-2026-6214, path traversal via crafted reference names, reached where convert-sha256 writes advertised refs to disk; and GO-2026-6213, worktree operations following symlinks. Pulls go-billy to v6.0.0-alpha.2 transitively (#105, #106)

  • Bumped the Go toolchain to 1.26.6 (go.mod toolchain and the mise pin), clearing ten standard-library advisories reachable from the HTTP transport and the convert-sha256 filesystem paths — among them quadratic complexity in net/url resolvePath, an os root escape via symlink plus trailing slash, and an HTTP/2 infinite loop on a bad SETTINGS_MAX_FRAME_SIZE. Released binaries are built from the go.mod toolchain, so they carried these until now; govulncheck ./... goes from 12 called vulnerabilities to 0 (#106)

  • Explicitly supplied credentials are now bound to the host you named. Under --source-follow-info-refs-redirect / --target-follow-info-refs-redirect, git-sync addresses the host /info/refs redirected to directly for the follow-up RPCs, and re-attached the configured token to those requests after Go's http.Client had deliberately stripped the Authorization header on the cross-host hop — so any redirect an attacker could cause (an open redirect on the real host, a hostile mirror, a man-in-the-middle on a plain-http source) collected a token that on the target side typically carries write access. A token now goes only to the host named on the command line and its subdomains, on the same port and without a scheme downgrade, and anything else falls through to the credential helper with a warning naming the withheld host. Credentials resolved from the helper are unaffected — those were always looked up keyed on the host being challenged (#108)

  • Ref names from a remote are validated before git-sync acts on them. Unchecked names reached two places where a malformed one matters: convert-sha256 writes them to disk, where refs/heads/../../config escapes refs/heads (go-billy clamps the traversal at the repository root, so the reachable damage is overwriting config, HEAD, or packed-refs); and ref-update commands embed them in the receive-pack request, where a NUL lets the source inject a capability list into the push git-sync sends to the target. Advertised names now go through plumbing.ReferenceName.Validate — git's own check_refname_format, so nothing git accepts is affected — at both decode boundaries, on the source and target side. A failing name is skipped with a quoted warning rather than failing the run; names supplied through --map are rejected outright at startup (#109)

  • A packfile object header can no longer request an unbounded allocation. ExtractCommitParents sized its per-object buffer from the size the pack header declares, which go-git validates only for varint overflow — so the value is remote-controlled up to int64, and a 48-byte pack declaring a 128 TiB object produced fatal error: out of memory, a Go runtime fatal an embedding process cannot contain with recover(). Declared sizes above 64 MiB are now rejected (this path reads tree:0-filtered packs, whose objects are commits and tags measured in kilobytes) and the initial buffer is capped at 64 KiB, so the allocation tracks bytes actually received. Reachable from ordinary planning, via FetchCommitParents during ancestry checks (#107)

  • SSH repository paths are now shell-quoted in full. shellQuotePath exempted the segment before the first slash when the path began with ~, leaving it for the remote login shell to expand — and a SCP-style URL puts attacker-influenceable text in exactly that position: git@host:~a;id/repo.git produced the remote command git-upload-pack ~a;id/'repo.git', executing id on the remote host as the authenticated SSH user. This matters wherever only part of the URL is caller-controlled, such as a repository path templated into a fixed host or one arriving from a webhook. Quoting the whole path costs nothing, because git-upload-pack resolves it through enter_repo(), which interpolates a leading ~ itself (#107)

  • Credentials embedded in a remote URL are no longer echoed. probe and fetch carried Source.URL/Target.URL verbatim into their results, which are printed to stdout and serialized into --json — so https://user:token@host/repo.git, the standard CI form, put the token in output that automation collects; the URL-parse failure path leaked it too, since url.Parse embeds the raw URL in its error. All of these now redact the whole userinfo component via the new internal/redact package, which also replaces url.URL.Redacted() in HTTP error messages: Redacted() masks only the password, leaving a token in the username position — how GitHub App and PAT URLs are normally written — in the clear (#107)

  • The materialized object limit is enforced while the pack streams, not after. FetchToStore filled the in-memory store with no cap, and --materialized-max-objects (default 500,000) was checked against the object closure only once the fetch had finished — by which point the objects were resident and the process may already have died, so the guard reported an overrun it exists to prevent. The store now enforces the count as objects are decoded, failing the write that would exceed the limit, with an ErrObjectLimit sentinel and an ObjectLimitError carrying the limit. The cap applies per fetch rather than cumulatively, because a store outlives one fetch and the materialized tag refill legitimately resends objects already present, so worst-case residency is the limit times the number of fetches — two on the sync path. The unstable Fetch path now actually threads MaterializedMaxObjects through, having previously accepted and dropped it (#110)

  • Advertisement reads are bounded on every transport. The HTTP path capped /info/refs at 64 MiB, but the SSH transport read the advertisement with a bare io.ReadAll and the remote-helper transport accumulated pkt-lines until a flush that a remote need never send — either grows until the process dies. All three now share one MaxAdvertisementBytes ceiling, and hitting it tears the producer down rather than leaving a subprocess blocked on a full pipe and hanging Wait forever (#107)

  • The commit-graph pack spill to disk is bounded. ExtractCommitParents copies a non-seekable pack into a temp file to give the parser a seekable source, with no limit on the copy, so a source could fill the disk during what is only a planning round trip. Capped at 4 GiB (#107)

  • Server-authored text is stripped of terminal control characters. Sideband progress, up to 64 KiB of HTTP error body, receive-pack ng rejection reasons, diagnostic response headers, and ssh's relayed remote: output all reached terminals, log files and --json results unfiltered, so a hostile remote could redraw the line its message was printed on — making a rejected push read as a successful one — or smuggle control characters into whatever ingests the JSON. All five paths now go through internal/sanitize, which drops everything below 0x20 plus DEL. Carriage return survives only in streamed progress, where git's in-place updates depend on it; one-shot messages lose it, because a bare CR rewrites the line on its own (rejected\rok refs/heads/main reads as a success with no escape sequence involved). Rejection classification still runs on the raw status, so filtering cannot change whether a rejection is treated as a concurrent move (#111)

  • Whether TLS verification is disabled is now derived from the transport. HTTPConn.InsecureSkipTLSVerify was a field callers had to set to match the client they passed in, and the cross-host credential guard depends on it — so a caller who disabled verification on their transport but forgot the field silently lost that protection, which is the wrong direction for a security check to fail. The transport is now inspected directly, following Unwrap through wrappers so an instrumentation layer cannot hide the setting; the explicit field still forces the guard on for transports that cannot be inspected (#111)

  • Release artifacts now carry signed build provenance. checksums.txt was the only integrity signal and was itself unsigned, so someone downloading a Linux archive plus its checksum had no way to establish that either came from this repository — macOS binaries were signed and notarized, but nothing else was. The release workflow now attests the archives and the checksum file via actions/attest-build-provenance, verifiable with gh attestation verify <file> --repo entireio/git-sync. README documents the check (#112)

  • Added permissions: contents: read to the Tests and License Check workflows, which previously inherited the repository default while running PR-controlled code. lint.yml already declared it. Defence in depth — GitHub already restricts the token for fork PRs — but same-repo branches are not restricted (#107)

Added

  • SyncPolicy.AllowEmptySource — an empty source can now be an outcome instead of an error, when a source of truth says so. Replicate previously failed any run whose planning produced no desired refs, with one message (no source refs matched) covering unrelated conditions: the source has no refs, the source has refs the requested scope excluded, and the source has refs this reader was never shown. A caller could not tell them apart, and the first is not always a failure — a mirror of a repository that has never been pushed to is trivially up to date. With the policy set, Replicate reports them separately: ErrNoRefsSelected when the source does advertise refs, ErrSourceEmptyUnverified / ErrTargetEmptyUnverified when either side's emptiness could not be established, ErrSourceEmptyTargetPopulated when the source is empty while the target still holds refs (a real divergence — refused rather than converged, since converging means deleting them), and a zero-plan success with ExecutionSummary.Converged when source and target are both empty and therefore already agree.

    git-sync does not decide that a repository is empty, and will not guess. It cannot: ref hiding is designed to be invisible to the client, so a hidden ref and an absent one are the same observation. An unborn HEAD does not close the gap either — git emits that line for any dangling HEAD, so a repository holding refs/heads/other with HEAD pointed at a never-created refs/heads/main reports unborn, and hiding that branch reduces its entire advertisement to the unborn line alone (verified against git 2.53). The assertion is therefore an input: SyncPolicy.SourceAssertedEmpty, which the caller supplies from a repository-state query that sees past hiding.

    The target needs the same treatment, via SourceAssertedEmpty's counterpart TargetAssertedEmpty, and for a sharper reason: receive.hideRefs omits matching refs from receive-pack's advertisement, so a populated target can advertise nothing but the bare capabilities^{} sentinel — and because receive.hideRefs and uploadpack.hideRefs are separate settings, a ref hidden from the push side is still served to fetchers, so a target wrongly judged empty is one whose readers see refs the source does not have.

    What git-sync contributes is a consistency check on those claims, and it only ever refuses. Before reporting convergence it independently requires an empty advertisement on each side, an unborn HEAD on the source (now requested via protocol v2's ls-refs=unborn where the server advertises it, and surfaced as RefService.HeadUnborn), no advertised ref name dropped as invalid on either side, and no visible target ref within the request's scope (an excluded namespace the run would neither push nor prune is not divergence). None of those can promote an absent assertion into a success, so a caller cannot get a false converge out of a compliant server, and a caller that supplies no assertion gets ErrSourceEmptyUnverified or ErrTargetEmptyUnverified no matter what the wire says. The unborn line's symref-target is deliberately not surfaced as SourceHEAD, which consumers read as a branch that exists.

    Off by default: without the opt-in an empty source still fails with the historical message, and the new sentinels deliberately do not carry that text so a caller still matching on it cannot mistake a divergence for the old benign no-op. The policy is replicate-only and requires RefScope.AllRefs — under a narrower scope the source ref listing is itself narrowed, so an empty result says nothing about the repository as a whole — and both requirements are now rejected at the request edge rather than accepted and silently discarded. Convergence also requires protocol v2 on the source leg, since the unborn cross-check has no v1 equivalent: ProtocolV1 is rejected at the request edge, and an auto source that falls back to v1 mid-run reports that the protocol cannot carry the signal rather than implying the server withheld refs.

    One thing does change for every v2 caller, opt-in or not: unborn is appended to each ls-refs request whose server advertises support for it (see docs/protocol.md). It adds no round trip and a source with commits answers exactly as before, but the request bytes differ, so a test asserting on the exact ls-refs body will need updating. Only the reader of the resulting flag is gated on the policy (#114)

  • A Vulnerability Scan workflow running govulncheck ./... on pull requests, pushes to main, and a weekly schedule. The existing lint suite cannot see this class of issue, and the weekly run matters because advisories are published against versions already in go.mod — without it, a newly disclosed vulnerability goes unreported until someone happens to open a PR (#106)