Repository navigation
v0.9.0
Security
-
Bumped
golang.org/x/cryptotov0.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 forv0.51.0; liftsgolang.org/x/nettov0.56.0andgolang.org/x/systov0.47.0(#104) -
Bumped go-git to
v6.0.0-alpha.5, clearing two advisoriesgovulncheckreported as reachable from git-sync's own call graph: GO-2026-6214, path traversal via crafted reference names, reached whereconvert-sha256writes advertised refs to disk; and GO-2026-6213, worktree operations following symlinks. Pullsgo-billytov6.0.0-alpha.2transitively (#105, #106) -
Bumped the Go toolchain to
1.26.6(go.modtoolchainand the mise pin), clearing ten standard-library advisories reachable from the HTTP transport and theconvert-sha256filesystem paths — among them quadratic complexity innet/urlresolvePath, anosroot escape via symlink plus trailing slash, and an HTTP/2 infinite loop on a badSETTINGS_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/refsredirected to directly for the follow-up RPCs, and re-attached the configured token to those requests after Go'shttp.Clienthad deliberately stripped theAuthorizationheader 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-httpsource) 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-sha256writes them to disk, whererefs/heads/../../configescapesrefs/heads(go-billy clamps the traversal at the repository root, so the reachable damage is overwritingconfig,HEAD, orpacked-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 throughplumbing.ReferenceName.Validate— git's owncheck_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--mapare rejected outright at startup (#109) -
A packfile object header can no longer request an unbounded allocation.
ExtractCommitParentssized 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 toint64, and a 48-byte pack declaring a 128 TiB object producedfatal error: out of memory, a Go runtime fatal an embedding process cannot contain withrecover(). Declared sizes above 64 MiB are now rejected (this path readstree: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, viaFetchCommitParentsduring ancestry checks (#107) -
SSH repository paths are now shell-quoted in full.
shellQuotePathexempted 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.gitproduced the remote commandgit-upload-pack ~a;id/'repo.git', executingidon 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, becausegit-upload-packresolves it throughenter_repo(), which interpolates a leading~itself (#107) -
Credentials embedded in a remote URL are no longer echoed.
probeandfetchcarriedSource.URL/Target.URLverbatim into their results, which are printed to stdout and serialized into--json— sohttps://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, sinceurl.Parseembeds the raw URL in its error. All of these now redact the whole userinfo component via the newinternal/redactpackage, which also replacesurl.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.
FetchToStorefilled 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 anErrObjectLimitsentinel and anObjectLimitErrorcarrying 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 unstableFetchpath now actually threadsMaterializedMaxObjectsthrough, having previously accepted and dropped it (#110) -
Advertisement reads are bounded on every transport. The HTTP path capped
/info/refsat 64 MiB, but the SSH transport read the advertisement with a bareio.ReadAlland 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 oneMaxAdvertisementBytesceiling, and hitting it tears the producer down rather than leaving a subprocess blocked on a full pipe and hangingWaitforever (#107) -
The commit-graph pack spill to disk is bounded.
ExtractCommitParentscopies 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
ngrejection reasons, diagnostic response headers, and ssh's relayedremote:output all reached terminals, log files and--jsonresults 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 throughinternal/sanitize, which drops everything below0x20plus 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/mainreads 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.InsecureSkipTLSVerifywas 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, followingUnwrapthrough 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.txtwas 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 viaactions/attest-build-provenance, verifiable withgh attestation verify <file> --repo entireio/git-sync. README documents the check (#112) -
Added
permissions: contents: readto theTestsandLicense Checkworkflows, which previously inherited the repository default while running PR-controlled code.lint.ymlalready 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:ErrNoRefsSelectedwhen the source does advertise refs,ErrSourceEmptyUnverified/ErrTargetEmptyUnverifiedwhen either side's emptiness could not be established,ErrSourceEmptyTargetPopulatedwhen 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 withExecutionSummary.Convergedwhen 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/otherwith HEAD pointed at a never-createdrefs/heads/mainreports 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 counterpartTargetAssertedEmpty, and for a sharper reason:receive.hideRefsomits matching refs from receive-pack's advertisement, so a populated target can advertise nothing but the barecapabilities^{}sentinel — and becausereceive.hideRefsanduploadpack.hideRefsare 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=unbornwhere the server advertises it, and surfaced asRefService.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 getsErrSourceEmptyUnverifiedorErrTargetEmptyUnverifiedno matter what the wire says. The unborn line'ssymref-targetis deliberately not surfaced asSourceHEAD, 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:ProtocolV1is rejected at the request edge, and anautosource 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:
unbornis appended to eachls-refsrequest whose server advertises support for it (seedocs/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 Scanworkflow runninggovulncheck ./...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)