Documentation
¶
Overview ¶
Package gitproxy is a git smart-HTTP interception proxy that runs outside the sandbox and decides, per ref, what a sandbox is allowed to push.
The hole it closes ¶
pkg/executor already requires every write-back branch to live under "cloop/", and pkg/writeback re-verifies the branch after the fact. Both of those checks run in code the sandbox itself executes or in code that inspects the result after the push has already landed. Neither is a boundary. A workload that ignores the SDK and runs `git push --force origin HEAD:main` with the credential it was handed reaches main, and the first anyone hears of it is the force-push notification.
The credential is the reason. In push mode the hub leases a GitHub PAT and delivers it *into* the sandbox, because that is where git runs. Everything downstream of that delivery is advisory: the sandbox holds an authority scoped to whole repositories and is asked politely to use a fraction of it.
The inversion ¶
This proxy moves git's authenticated leg out of the sandbox. The sandbox's remote is rewritten to point here, and it authenticates with an ephemeral session token that is worth nothing anywhere else. The proxy holds the real credential, parses the receive-pack command list before forwarding a byte of it, checks every ref update against the session's policy, and only then presents the credential upstream.
sandbox ──session token──▶ gitproxy ──PAT──▶ github.com
│
Policy.Decide per ref
So "push to whitelisted branches only" stops being a convention the sandbox is trusted to honour and becomes a property of the network path. A sandbox that tries refs/heads/main gets a refusal in git's own vocabulary and an audit row; a sandbox that leaks its token leaks the right to push to refs/heads/cloop/** for the rest of the task, and nothing else.
What it does not decide ¶
Whether an update is a fast-forward. That needs the object graph, which the proxy does not have and would have to index a pack to get. Fast-forward enforcement belongs to the forge, where the graph already is — GitHub branch protection, or receive.denyNonFastForwards. This package is deliberately explicit about the boundary rather than implying a check it cannot make: what it enforces is *which refs* a sandbox may touch and *in which direction* (create, update, delete), which is exactly what stops a sandbox reaching a branch a human owns.
Signed pushes are refused outright. See ParseReceivePack.
Transport ¶
Serve() takes a listener, so TLS is the caller's to configure through pkg/tlsconf. The base URL must be https: the sandbox presents its session token as an Authorization header on every request, and a sandbox is by construction something that may be sharing a host with whatever else is listening on loopback.
Index ¶
- Constants
- Variables
- func AdvertisedBaseURL(advertise string, bound net.Addr) (string, error)
- func ForgeRoute(base, host string) (string, error)
- func NormalizeBaseURL(raw string) (string, error)
- func NormalizeRepoPath(p string) string
- func RenewMargin(ttl time.Duration) time.Duration
- func RenewTick(ttl time.Duration) time.Duration
- func UpstreamHostBase(raw string) (string, error)
- func UpstreamRepoPath(upstream string) (string, error)
- func ValidateRefName(ref string) error
- func ValidateRepoPattern(pattern string) error
- type Credential
- type Decision
- type DenyReason
- type Event
- type EventKind
- type MintRequest
- type Minted
- type Options
- type Policy
- func (p Policy) AllowsRef(ref string) bool
- func (p Policy) Decide(u RefUpdate) error
- func (p Policy) DecideAll(cmds []RefUpdate) ([]Decision, bool)
- func (p Policy) IsZero() bool
- func (p *Policy) Normalize()
- func (p Policy) RefSummary() string
- func (p Policy) Restricted() bool
- func (p Policy) Validate() error
- type Proxy
- type PushScan
- type PushScanFunc
- type ReceivePack
- type RefDenial
- type RefUpdate
- type RefreshFunc
- type Refreshed
- type Registry
- func (r *Registry) Authenticate(id, token string) (*Session, error)
- func (r *Registry) CheckRestore(rec SessionRecord) error
- func (r *Registry) Close(id, reason string)
- func (r *Registry) CloseForGrant(leaseID, grantID, reason string) int
- func (r *Registry) CloseForLease(leaseID, reason string) int
- func (r *Registry) Known(id string) bool
- func (r *Registry) LeaseSessions(leaseID string) []*Session
- func (r *Registry) Mint(req MintRequest) (*Minted, error)
- func (r *Registry) ReapExpired() int
- func (r *Registry) Renew(id string, ttl time.Duration) (time.Time, error)
- func (r *Registry) RenewDue(id string, ttl time.Duration) (bool, time.Time, error)
- func (r *Registry) RenewalDue(id string, ttl time.Duration) bool
- func (r *Registry) Restore(req RestoreRequest) (*Session, error)
- func (r *Registry) Session(id string) (*Session, error)
- func (r *Registry) Sessions() []*Session
- func (r *Registry) Suspend(id, reason string) bool
- func (r *Registry) SuspendForLease(leaseID, reason string) []string
- type RestoreRequest
- type Route
- type ScanFinding
- type ScanLimits
- type ScanMode
- type ScanSecret
- type Session
- func (s *Session) AllowsRepo(repoPath string) bool
- func (s *Session) CloseReason() string
- func (s *Session) Closed() bool
- func (s *Session) CredentialExpiresAt() time.Time
- func (s *Session) Durable() bool
- func (s *Session) Expired(now time.Time) bool
- func (s *Session) ExpiresAt() time.Time
- func (s *Session) Refreshable() bool
- func (s *Session) RenewedAt() time.Time
- func (s *Session) Scoped() bool
- func (s *Session) Stats() Stats
- func (s *Session) Used() bool
- type SessionRecord
- type SessionStore
- type Stats
Constants ¶
const ( ScanResultClean = "clean" ScanResultSecret = "secret" ScanResultLimit = "limit" ScanResultUnreadable = "unreadable" ScanResultIncomplete = "incomplete" ScanResultUnscanned = "unscanned" )
Results a scan is counted under (cloop_gitproxy_push_scans_total).
const ( SettingPushScan = "executors.git_proxy.push_scan" SettingPushScanObjectMB = "executors.git_proxy.push_scan_max_object_mb" SettingPushScanPushMB = "executors.git_proxy.push_scan_max_push_mb" SettingPushScanSeconds = "executors.git_proxy.push_scan_seconds" )
Configuration keys behind the scan, named in refusals so the operator who reads one knows what to change. pkg/config's yaml tags spell the same keys, and a test there holds the two together.
const ( // DefaultScanMaxObjectBytes bounds one object's inflated bytes. DefaultScanMaxObjectBytes int64 = defaultPushScanObjectMB * pushScanMiB // DefaultScanMaxPushBytes bounds a whole push's inflated bytes. It is // what stops a pack built to inflate a thousandfold from holding a core. DefaultScanMaxPushBytes int64 = defaultPushScanPushMB * pushScanMiB // DefaultScanMaxDuration bounds the time spent scanning — inflating and // matching — not the time spent waiting on the sandbox or the forge, so // a slow link does not count against it. DefaultScanMaxDuration = defaultPushScanSeconds * time.Second )
Default limits. A push is refused past any of them.
const CredentialRefreshWindow = 10 * time.Minute
CredentialRefreshWindow is how much of an upstream credential's life may remain before a session with a refresher renews it. It matches the broker's window (secretbroker.AppTokenRefreshWindow), which this package does not import.
const DefaultListenAddr = "127.0.0.1:0"
DefaultListenAddr is where a hub binds the git proxy when listen_addr is unset: loopback, on a port chosen at startup.
const DefaultMaxCommands = 16
DefaultMaxCommands caps the ref updates one push may carry.
A write-back moves one branch. Anything with more than a handful of commands is either a mirror push or an attempt to find a ref the allowlist forgot, and neither is what a sandbox is for.
const DefaultMaxPackBytes int64 = 2 << 30 // 2 GiB
DefaultMaxPackBytes bounds one pushed pack.
The proxy streams the pack rather than buffering it, so this is not a memory bound — it is a bound on what one sandbox session can push through the hub's credential in a single request.
const DefaultSessionTTL = time.Hour
DefaultSessionTTL bounds one sandbox's access to the proxy.
It is the length of a task's git traffic, not the length of a task: the session is minted when a workload is dispatched and the clone and the write-back push both happen inside it. An hour covers a slow clone of a large repository over a slow link and still expires long before anything that leaked the token could be used at leisure.
const ForgeRouteSegment = "_forge"
ForgeRouteSegment is the path segment the proxy serves a forge host other than github.com beneath: /_forge/<host>/<owner>/<name>. No GitHub owner can be named it — an owner name may not begin with an underscore — and the route's four segments cannot be mistaken for a repository's two.
const MaxRefPatterns = 256
MaxRefPatterns bounds an allowlist. A policy with thousands of patterns is a configuration mistake, and matching every one of them against every ref in every push is work an attacker would otherwise get to choose the size of.
const MaxRepoPatterns = 64
MaxRepoPatterns bounds the allowlist a single session may carry.
Every pattern is tested on every request, so an unbounded list is a way to make each git operation arbitrarily expensive. The limit is far above any real grant — an allowlist is a handful of globs, not an inventory.
const MaxSessionTTL = 12 * time.Hour
MaxSessionTTL is the ceiling an operator cannot configure past. A git proxy session is a standing authority to push, and a long-lived one is indistinguishable from having handed the sandbox the credential itself.
Variables ¶
var ( // ErrSessionExists: the registry already holds a session with that id. ErrSessionExists = errors.New("gitproxy: a session with that id is already live") // ErrSessionLapsed: the session's TTL ran out before it could be restored. ErrSessionLapsed = errors.New("gitproxy: session lapsed before it could be restored") )
Errors Restore distinguishes.
var ( // ErrUnauthenticated covers every authentication failure — unknown // session, wrong token, expired, closed. They are one error on purpose: a // caller that could tell "no such session" from "wrong token" would be an // oracle for enumerating session IDs. ErrUnauthenticated = errors.New("gitproxy: unauthenticated") // ErrSessionNotFound is for management operations, where the caller is the // operator rather than the sandbox and precision is the point. ErrSessionNotFound = errors.New("gitproxy: session not found") // ErrWrongRepo means an authenticated session addressed a repository it // was not minted for. ErrWrongRepo = errors.New("gitproxy: session is not scoped to this repository") )
Errors a caller distinguishes.
var AllDenyReasons = []DenyReason{ DenyRefNotAllowed, DenyDelete, DenyCreate, DenyUpdate, DenyNoWrite, DenyTooManyCommands, DenyPushCert, DenySecretInPush, DenyScanLimit, DenyUnreadablePack, }
AllDenyReasons is every classification, for tests that assert the docs and dashboards cover the whole set.
var DefaultAllowedRef = "refs/heads/" + executor.WriteBackBranchPrefix + "**"
DefaultAllowedRef is the allowlist a policy gets when it names none.
It is the write-back namespace and nothing else. pkg/executor already requires every write-back branch to sit under this prefix so that the hub can force-update it without being able to overwrite a branch a human owns — but that rule is enforced by code running *inside* the sandbox, which makes it a convention rather than a boundary. Restating it here, outside, is what turns it into one.
var ErrCredentialRefused = errors.New("gitproxy: upstream credential will not be renewed")
ErrCredentialRefused is what a RefreshFunc wraps when the upstream credential will not be renewed, now or later. The session is closed.
var ErrRefDenied = errors.New("ref update denied by policy")
ErrRefDenied is the refusal. Every rejected command wraps it, so a caller can distinguish "the policy said no" — an expected, reportable outcome — from a malformed request or a broken upstream.
var ErrWrongForge = errors.New("gitproxy: session is for another forge host")
ErrWrongForge means an authenticated session addressed another forge host's route than the one its credential is for.
var ScanResults = []string{ ScanResultClean, ScanResultSecret, ScanResultLimit, ScanResultUnreadable, ScanResultIncomplete, ScanResultUnscanned, }
ScanResults is every result label, for tests that hold the docs to the set.
Functions ¶
func AdvertisedBaseURL ¶ added in v0.0.5
AdvertisedBaseURL is the base a hub points sandboxes at for a proxy bound to bound: advertise_url when it is set, otherwise the bound address itself — with an unspecified bind named as 127.0.0.1, because 0.0.0.0 is a bind address and never a destination. The result is what NewRegistry is given, and so is still subject to NormalizeBaseURL.
The bound address is only correct when the sandbox shares the hub's network namespace, which is why an operator running containers, Pods or edge devices sets advertise_url. Falling back to it anyway — rather than refusing — keeps the single-host case working with no configuration, and a wrong choice here surfaces as a fetch that cannot connect rather than as a credential going somewhere it should not.
func ForgeRoute ¶ added in v0.0.6
ForgeRoute returns the URL beneath base that the sandbox's https://<host>/ is rewritten to: base itself for github.com, base/_forge/<host> for any other host. host is canonicalised; one that is not usable in a URL path — an IPv6 literal — is refused.
func NormalizeBaseURL ¶ added in v0.0.5
NormalizeBaseURL checks the public base a sandbox clones from and returns it as scheme://host[:port]. It is the rule NewRegistry applies, exported so that config validation and `cloop hub doctor` ask it rather than restate it (Task 20387): three copies of one rule were three chances to disagree about which advertise_url starts a proxy.
https is required for the same reason pkg/executor requires it of a workspace repo: the sandbox presents its session token as an Authorization header on every request, and over cleartext that token is published rather than delivered. A loopback proxy is not an exception — a sandbox is, by construction, something that might be sharing the host.
The host must name a host, not merely be non-empty: "https://:8443" has a Host of ":8443", and every sandbox handed it would dial nowhere. A port must be one a client can dial.
func NormalizeRepoPath ¶ added in v0.0.2
NormalizeRepoPath renders an owner/name path in the form the matcher and the audit trail both use: no leading or trailing slash, no ".git" suffix, and lowercased, because GitHub treats repository paths case-insensitively and a matcher that did not would let "Acme/Tool" slip past an "acme/*" allowlist.
Normalisation only. It does not decide whether the result is usable — that is validRepoSegment's job, and the two are separate because the audit trail wants to record what was asked for even when the answer is no.
func RenewMargin ¶ added in v0.0.6
RenewMargin is how close to its deadline a session is renewed: five minutes, or half its TTL for a shorter one.
func RenewTick ¶ added in v0.0.6
RenewTick is how often a renewer looks: a minute, or a quarter of the TTL for a shorter one — at least twice inside RenewMargin either way.
func UpstreamHostBase ¶ added in v0.0.2
UpstreamHostBase checks the forge base a scoped session forwards to.
A scoped session's Upstream is a host, not a repository, so it is validated against the opposite requirement from UpstreamRepoPath: there must be *no* path, because every path segment on a scoped session comes from the request and a base with one would silently prefix them all.
func UpstreamRepoPath ¶
UpstreamRepoPath extracts the "owner/name" a proxy serves an upstream under.
The shape is not cosmetic. pkg/executor's Workspace.RepoPath parses exactly this out of a clone URL to match a GitHub grant's repository allowlist, so a proxy URL that did not preserve it would break grant matching for every workspace routed through the proxy.
func ValidateRefName ¶
ValidateRefName applies git's ref naming rules to a name arriving from a sandbox.
The proxy forwards this string to a real git server, so a name that git would refuse must be refused here rather than passed along: the rules below are what stop "refs/heads/../../x" and "refs/heads/ --upload-pack=sh" from being this proxy's problem to reason about.
func ValidateRepoPattern ¶ added in v0.0.2
ValidateRepoPattern checks one owner/name glob.
The shape requirement — exactly one slash, or the bare "*" wildcards — is what keeps path.Match's semantics predictable here: "*" does not cross a separator, so "acme/*" matches acme/tool and not acme/group/tool. A pattern with a different shape could only ever match paths this proxy rejects anyway, so it is a mistake worth reporting at mint time rather than a silently dead entry in an allowlist someone is relying on.
Types ¶
type Credential ¶
type Credential struct {
Username string
Password string
// GrantID and LeaseID are audit bookkeeping. Neither is secret.
GrantID string
LeaseID string
}
Credential is what the proxy presents upstream on the sandbox's behalf.
It is the whole point of the design. The sandbox never holds this — it holds a session token that works against this proxy, for this repository, for this TTL, under this policy, and nowhere else. A token that escapes a sandbox buys an attacker exactly the authority the policy already granted, which is "push to refs/heads/cloop/**", rather than everything the underlying PAT can reach.
func (Credential) Empty ¶
func (c Credential) Empty() bool
Empty reports whether there is nothing to present upstream.
type Decision ¶
Decision pairs one command with its verdict, for reporting and for audit.
func (Decision) Reason ¶
Reason renders the refusal as a single line fit for git's status report, which is newline-delimited and would otherwise be split by a multi-line message into a status line and garbage.
The ref itself is left out when the refusal carries it separately: every caller renders the reason beside the ref already.
type DenyReason ¶
type DenyReason string
DenyReason classifies a refusal into a closed set.
The prose in a refusal names the ref and the allowlist that excluded it, so it is exactly as varied as the branch names a sandbox chooses to push. That makes it unusable as a metric label: a sandbox pushing refs/heads/1, refs/heads/2, ... would mint one Prometheus series per attempt, turning a probe of the allowlist into memory pressure on the hub watching it. The reason code is chosen by this package from the constants below, so the number of buckets is fixed no matter what a sandbox sends.
const ( // DenyRefNotAllowed: the ref is outside the session's branch allowlist. // A sandbox hitting this repeatedly is enumerating the allowlist. DenyRefNotAllowed DenyReason = "ref_not_allowed" // DenyDelete: deleting refs is not permitted for this session. DenyDelete DenyReason = "delete_denied" // DenyCreate: creating refs is not permitted for this session. DenyCreate DenyReason = "create_denied" // DenyUpdate: updating existing refs is not permitted for this session. DenyUpdate DenyReason = "update_denied" // DenyNoWrite: the session's policy carries no write capability at all. DenyNoWrite DenyReason = "no_write" // DenyTooManyCommands: the push carries more ref updates than allowed. DenyTooManyCommands DenyReason = "too_many_commands" // DenyPushCert: signed pushes are not supported. DenyPushCert DenyReason = "push_cert" // DenySecretInPush: the push carries a credential the session's run was // given (Task 20436). DenySecretInPush DenyReason = "secret_in_push" // DenyScanLimit: the push could not be scanned within the proxy's size or // time limits, so it is refused rather than forwarded unread. DenyScanLimit DenyReason = "scan_limit" // DenyUnreadablePack: the proxy could not read the push's pack. DenyUnreadablePack DenyReason = "unreadable_pack" )
func DenyReasonOf ¶
func DenyReasonOf(err error) (DenyReason, bool)
DenyReasonOf extracts the classification from anything in an error's chain, falling back to DenyRefNotAllowed for a refusal that predates classification — the conservative choice, since that is the reason a security-minded operator most wants counted rather than dropped.
type Event ¶
type Event struct {
Kind EventKind `json:"kind"`
SessionID string `json:"session_id,omitempty"`
RepoPath string `json:"repo_path,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Actor string `json:"actor,omitempty"`
// RunID is the run the session serves, on a push_secret_refused row: what
// the hub files the refusal in the run's history by.
RunID string `json:"run_id,omitempty"`
// Refs are the ref names the request touched, with any of the run's
// credentials a name carries taken out.
Refs []string `json:"refs,omitempty"`
// Detail is a human-readable reason or summary.
Detail string `json:"detail,omitempty"`
// Findings are where a push_secret_refused push carries a credential.
Findings []ScanFinding `json:"findings,omitempty"`
// Scan is set on a push the scan refused (Task 20436): ScanResultSecret
// on a push_secret_refused row, ScanResultLimit or ScanResultUnreadable
// on a push_denied one.
Scan string `json:"scan,omitempty"`
At time.Time `json:"at"`
}
Event is one observation for the compliance trail.
It carries no credential and no object content: the fields are IDs, ref names and reasons, all of which are safe to write to a log an operator reads. The audit value is in the refusals — a push_denied naming refs/heads/main is a sandbox that tried, and nothing else in the system would have recorded it.
type EventKind ¶
type EventKind string
EventKind names what happened.
const ( // EventSessionMinted records a session coming into existence. EventSessionMinted EventKind = "session_minted" // EventSessionClosed records one going away, by revocation or expiry. EventSessionClosed EventKind = "session_closed" // EventSessionRenewed records a session's deadline moved by Renew. EventSessionRenewed EventKind = "session_renewed" // EventPushAllowed records a push whose every command passed policy — and, // unless the push scan is off, whose pack carried none of the run's // credentials. It is emitted before the forge's verdict is forwarded, so // it describes what was authorised, not what the remote ultimately // accepted. EventPushAllowed EventKind = "push_allowed" // EventPushDenied records a push refused by policy. This is the row that // matters: it is the evidence that the boundary held, and the only place a // sandbox's attempt to reach a protected branch is written down. EventPushDenied EventKind = "push_denied" // EventPushSecretRefused records a push refused because it carries a // credential the session's run was given (Task 20436). Its Findings name // the objects and the grant and secret each value came from — never the // value. EventPushSecretRefused EventKind = "push_secret_refused" // EventFetch records a read through the proxy. EventFetch EventKind = "fetch" // EventRejected records a request refused before policy ran at all — // unauthenticated, wrong repository, malformed, or a route this proxy does // not serve. EventRejected EventKind = "rejected" )
const EventSessionRestored EventKind = "session_restored"
EventSessionRestored records a session taken over from another hub process and restored into this registry.
type MintRequest ¶
type MintRequest struct {
// Upstream is the real https repository URL the proxy will forward to —
// or, when RepoPatterns is set, the forge host base (https://github.com)
// beneath which the allowlisted repositories live.
Upstream string
// RepoPatterns, when non-empty, mints a *scoped* session: one that admits
// every repository matching an owner/name glob, rather than the single
// repository Upstream names. Upstream must then be a bare host base.
//
// This is the mode a user's GitHub PAT grant uses, because its allowlist
// ("acme/*") names a set that only the forge can enumerate. See scope.go.
RepoPatterns []string
// ForgeHost is the forge host Credential is good against (Task 20423):
// github.com, or the GitHub Enterprise Server host the grant names. The
// session is served on that host's route alone, and a pinned session's
// Upstream must be on it, as must a scoped one's for any host but
// github.com. Empty is github.com for a scoped session and Upstream's own
// host for a pinned one — what every request meant before hosts were
// recorded.
ForgeHost string
// Credential authenticates the proxy upstream. Optional: a public
// repository needs none, though pushing to one generally does.
Credential Credential
// CredentialExpiresAt is when the forge stops honouring Credential — a
// GitHub App installation token's hour. Zero for a credential that does
// not expire on a clock the hub knows (a PAT).
CredentialExpiresAt time.Time
// Refresh, when set, re-mints Credential before CredentialExpiresAt
// (Task 20375). The session calls it from its request path once
// CredentialRefreshWindow or less of the credential's life remains,
// single-flight, presents the result to every later upstream request, and
// retires the superseded credential once no request presents it. Requires
// CredentialExpiresAt. See refresh.go.
Refresh RefreshFunc
// Policy is what the sandbox may do. Zero value gets WriteBackPolicy.
Policy Policy
// TTL bounds the session. 0 means DefaultSessionTTL; anything above
// MaxSessionTTL is an error rather than a silent clamp, because a caller
// that asked for a day and got twelve hours would discover it as a failed
// push halfway through.
TTL time.Duration
ProjectID string
TaskID string
ExecutorID string
Actor string
// RunID names the run the session is for, recorded with it.
RunID string
// Durable records the session in the registry's Store (Task 20383), so
// that the hub process adopting its run after this one stops can restore
// it under the same id and token. The lease path sets it: its sessions
// stand on the run's lease, which the adopter takes over and re-derives
// the upstream credential from. A session no lease is taken over for —
// a workspace's, whose credential stands on a lease of its own — would
// have nothing to be restored with, and is left out.
Durable bool
// OnEnd, when set, runs once after the session has left the registry —
// closed, reaped after its TTL, or closed at shutdown — on the goroutine
// that ended it and outside the registry's lock.
//
// It is for releasing what the upstream credential stands on. A GitHub
// App installation token is destroyed at GitHub when the lease that
// minted it is released, and a session presents that token upstream for
// its whole life: releasing the lease when the credential was *delivered*
// left the session holding a dead token (Task 20349).
OnEnd func()
}
MintRequest describes the session to create.
type Minted ¶
type Minted struct {
Session *Session
// Token is the plaintext session token. The registry keeps only its hash,
// so this is the only copy that will ever exist.
Token string
// RepoURL is what the sandbox uses as its remote: the proxy's public base
// with the session's repository path — or, for a scoped session, the route
// its forge host's URLs are rewritten to (ForgeRoute). It carries no
// credential.
RepoURL string
}
Minted is a new session plus the one-time secret.
func (Minted) Credential ¶
func (m Minted) Credential() Credential
Credential renders what a driver delivers into the sandbox: a basic credential whose username is the session ID and whose password is the token.
type Options ¶
type Options struct {
// Transport carries the proxy's requests to the forge. nil gets a
// hardened default. It exists so an operator whose forge sits behind a
// private CA can supply one — the same case pkg/tlsconf exists for — and
// so a test can point the upstream leg at a self-signed fixture without
// weakening the default.
Transport http.RoundTripper
// Fallback, when set, is offered every request whose credential names a
// session this registry has never heard of, before it is refused
// (Task 20354). Several hub processes may serve one control plane, each
// with its own registry, and a sandbox's request reaches whichever of
// them the network sends it to; the hub forwards it to the process that
// minted the session. It reports whether it answered the request.
// Credentials naming a session this registry knows — right or wrong
// token, live or expired — are always decided here.
Fallback func(w http.ResponseWriter, r *http.Request, sessionID string) bool
// Routes hands requests under other path prefixes on the proxy's
// listener to other handlers, ahead of git routing: the hub serves its
// model relay for sandboxed runs beside the git proxy (Task 20435), so a
// sandbox reaches both at one address, over one certificate, through one
// firewall rule. A prefix must begin "/_" — no forge owner begins with
// an underscore, so no repository path can be taken for one (as for
// ForgeRouteSegment) — and is matched at a "/" boundary.
Routes []Route
// PushScan decides how a push through a session is scanned for the
// credentials its run holds (Task 20436): the mode, the values, the
// limits. nil scans every push in ScanValues mode for the one value the
// proxy knows without being told — the session's own upstream token —
// within the default limits. Whatever it returns, the session's upstream
// token is matched too.
PushScan PushScanFunc
}
Options tunes the upstream leg.
type Policy ¶
type Policy struct {
// AllowedRefs are glob patterns over full ref names. A pattern without a
// refs/ prefix is read as a branch name and gets refs/heads/ prepended, so
// "cloop/*" and "refs/heads/cloop/*" mean the same thing.
//
// Matching follows path.Match: "*" does not cross a "/". A trailing "/**"
// matches any depth below the prefix, which is what a per-task namespace
// like cloop/task-42/fixup needs.
AllowedRefs []string `json:"allowed_refs,omitempty"`
// AllowCreate permits a push that brings a ref into existence. Creating a
// new branch under an allowed namespace is the ordinary write-back case, so
// this is the one authority a caller almost always wants.
AllowCreate bool `json:"allow_create,omitempty"`
// AllowUpdate permits moving an existing allowed ref to a new commit. A
// retried task legitimately replaces its own predecessor.
AllowUpdate bool `json:"allow_update,omitempty"`
// AllowDelete permits removing an allowed ref. Off by default: deleting is
// not something a write-back ever needs, and a name-only allowlist that
// forgot about deletes would let a sandbox destroy the very branches it was
// scoped to.
AllowDelete bool `json:"allow_delete,omitempty"`
// AllowFetch permits the read half of the protocol — git-upload-pack — over
// the same session. A sandbox that clones through the proxy needs it; one
// that only pushes back a tree it was handed does not.
AllowFetch bool `json:"allow_fetch,omitempty"`
// MaxCommands caps ref updates per push. 0 means DefaultMaxCommands.
MaxCommands int `json:"max_commands,omitempty"`
// MaxPackBytes caps the request body. 0 means DefaultMaxPackBytes.
MaxPackBytes int64 `json:"max_pack_bytes,omitempty"`
// RestrictRefs is a second allowlist that a ref must *also* match. Empty
// means no second restriction, so only AllowedRefs applies.
//
// It exists so that one party can narrow another's allowlist without
// either rewriting it. The hub's configured allowlist is AllowedRefs, the
// ceiling; a grant that says "this project may push to feature/* only"
// arrives here, and a ref has to clear both. The alternative, computing
// one list that is the intersection of the two, is not possible in
// general: the intersection of two sets of globs is not always a third
// set of globs. Two lists that both have to match are always exact.
//
// Patterns take the same syntax and normalisation as AllowedRefs. Unlike
// AllowedRefs, an empty list gets no default. Silence here means "the
// grant added nothing", not "fall back to the write-back namespace".
RestrictRefs []string `json:"restrict_refs,omitempty"`
}
Policy is what a sandbox is allowed to do to a repository's refs.
It is deny-by-default in every dimension. An empty AllowedRefs is not "no restriction", it is the built-in write-back namespace; an unset AllowDelete is a refusal, not an omission. The failure mode of a permissive default here is a sandbox force-pushing over main, which is exactly the outcome the subsystem exists to make impossible, so there is no default that trades safety for convenience.
func WriteBackPolicy ¶
func WriteBackPolicy() Policy
WriteBackPolicy returns the policy the executor write-back path needs: create and update inside the cloop/ namespace, no deletes, no fetch.
It is a constructor rather than a documented recipe because every caller that hand-assembled these four booleans would be a caller that could get one wrong, and three of the four failures are silent.
func (Policy) AllowsRef ¶
AllowsRef reports whether the ref name clears both allowlists: some pattern in AllowedRefs admits it and, when RestrictRefs names anything, some pattern there admits it too.
func (Policy) Decide ¶
Decide returns nil if the update is permitted, or an error wrapping ErrRefDenied whose text is shown to the person running git push.
The two checks are ordered name-first deliberately. A sandbox probing for a ref it is not allowed to touch learns the same thing either way, but an operator reading the audit trail wants "main is not in the allowlist" rather than "delete is not permitted", because the first names the real problem.
The name refusals carry the ref separately from their prose (RefDenial.Ref), so the reason git shows next to the ref does not repeat it. That reason is cut to one short line for git's status report (see elide), and a long branch name restated inside it pushed the one part the reader needs — the refs that would have been accepted — past the cut (found in Task 20340's live run).
func (Policy) DecideAll ¶
DecideAll evaluates every command.
A push is atomic here whatever the client asked for: if any command is refused, none is forwarded. Partially applying a push would mean the sandbox discovers the allowlist by watching which half of its commands landed, and leaves the repository in a state neither side asked for.
func (Policy) IsZero ¶
IsZero reports whether the policy grants nothing and names nothing.
It distinguishes "the caller never filled this in" from "the caller deliberately wrote a policy that permits nothing", which Validate refuses. Only the first is safe to replace with a default; treating the second as an invitation to substitute WriteBackPolicy would silently widen a policy somebody wrote to be narrow.
func (*Policy) Normalize ¶
func (p *Policy) Normalize()
Normalize fills defaults and canonicalises patterns. It is idempotent.
func (Policy) RefSummary ¶ added in v0.0.3
RefSummary renders the allowlist as one line for audit rows and the summaries shown to an operator: the ceiling, then the narrowing when there is one.
func (Policy) Restricted ¶ added in v0.0.3
Restricted reports whether a second allowlist narrows this policy.
type Proxy ¶
type Proxy struct {
// contains filtered or unexported fields
}
Proxy serves the git smart-HTTP protocol for the sessions in a Registry.
The zero value is not usable; call New.
func (*Proxy) Serve ¶
Serve accepts connections on ln until Close. The caller supplies the listener, so a TLS listener from pkg/tlsconf drops straight in.
func (*Proxy) ServeHTTP ¶
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP implements the git smart-HTTP routes.
Everything outside those routes is a 404. The dumb-HTTP protocol in particular is not served: it reads objects and packs straight out of the repository, which would let a sandbox fetch by path and bypass every check below.
type PushScan ¶ added in v0.0.7
type PushScan struct {
Mode ScanMode
// Secrets are the values the session's run holds. The session's own
// upstream credential is added to them by the proxy.
Secrets []ScanSecret
Limits ScanLimits
}
PushScan is how one push through a session is scanned.
type PushScanFunc ¶ added in v0.0.7
PushScanFunc decides how a push through sess is scanned. The proxy calls it once per push, on the request goroutine.
type ReceivePack ¶
type ReceivePack struct {
// Commands are the ref updates, in wire order.
Commands []RefUpdate
// Capabilities are the tokens the client advertised after the NUL on the
// first command line — "report-status", "side-band-64k", "agent=git/2.43"
// and so on.
Capabilities []string
// Shallow lists the object names from any shallow lines. They are carried
// so a caller can see them; nothing here decides on them.
Shallow []string
}
ReceivePack is the head of a push: everything before the pack file.
func ParseReceivePack ¶
ParseReceivePack reads the command section of a git-receive-pack request.
It returns the parsed head and a reader that replays the entire body — the consumed command bytes followed by the untouched pack — so a caller that decides to allow the push forwards exactly what the client sent. See pktReader for why replay rather than re-encode.
A push carrying a push certificate is refused. With push-cert the command list lives inside a signed block and the remote applies *that* copy, so enforcing a branch allowlist against the unsigned lines would be enforcing it against bytes nothing acts on. Refusing is the only honest answer available without full certificate parsing, and a proxy that silently passed one through would be a branch allowlist with a documented bypass.
func (*ReceivePack) HasCapability ¶
func (r *ReceivePack) HasCapability(name string) bool
HasCapability reports whether the client advertised a bare capability token.
func (*ReceivePack) WantsReportStatus ¶
func (r *ReceivePack) WantsReportStatus() bool
WantsReportStatus reports whether the client will read a status report.
This decides how a refusal is delivered. A client that asked for a report can be told which ref was refused and why, in git's own vocabulary, and will exit non-zero having printed it. A client that did not ask has no channel for that and must be refused at the HTTP layer instead — a silent 200 to a client that cannot read a report is a push that reports success and moved nothing.
func (*ReceivePack) WantsSideband ¶
func (r *ReceivePack) WantsSideband() bool
WantsSideband reports whether the status report must be side-band framed.
type RefDenial ¶
type RefDenial struct {
Reason DenyReason
Message string
// Ref, when set, is the ref the refusal is about. Error names it, so the
// error is self-contained wherever it is logged; Decision.Reason leaves it
// out, because git's status line already prints it and the reason is cut
// to one short line (see Decide).
Ref string
}
RefDenial is a policy refusal carrying both its prose and its class.
Error renders exactly as the fmt.Errorf it replaced — the sentinel's text, a colon, then the message — because Decision.Reason strips that prefix to build git's status line, and the sandbox-facing wording of a refusal is covered by tests.
type RefUpdate ¶
RefUpdate is one line of a push: move Ref from Old to New.
The SHAs are the client's claim, not a verified fact. Old in particular is what the client believes the remote currently holds; the remote checks it, this proxy does not, and no decision here may depend on it being true. What the proxy does depend on is the *shape* — whether either side is the zero SHA — because that is what distinguishes a create from a delete from an update, and those are three different authorities.
func (RefUpdate) Action ¶
Action names the authority this update needs, for policy and for audit rows.
type RefreshFunc ¶ added in v0.0.5
RefreshFunc renews a session's upstream credential. held is the password the session presents now. An error wrapping ErrCredentialRefused is final; any other is retried.
type Refreshed ¶ added in v0.0.5
type Refreshed struct {
// Credential is presented upstream from now on.
Credential Credential
// ExpiresAt is when the forge stops honouring it.
ExpiresAt time.Time
// Retire ends the credential this one superseded — destroys a GitHub App
// token at GitHub. The session calls it once, after the last request
// presenting the old credential has finished. Nil when there is nothing to
// retire, which is also what a refresher returns when another caller had
// already renewed the credential.
Retire func()
}
Refreshed is a renewed upstream credential.
type Registry ¶
type Registry struct {
// BaseURL is the https base the sandbox reaches the proxy at, e.g.
// "https://hub.internal:8443". Sessions render their RepoURL beneath it.
BaseURL string
// Now is the clock, injectable for tests. nil means time.Now.
Now func() time.Time
// OnEvent, if set, receives every authorisation decision. It runs on the
// request goroutine, so an implementation that blocks blocks a push.
OnEvent func(Event)
// Store, if set, records every Durable session and its end (durable.go),
// so another hub process can restore it. Nil keeps sessions in memory
// only, as every registry did before Task 20383.
Store SessionStore
// contains filtered or unexported fields
}
Registry holds live sessions.
It is the hub-side half of the proxy and has no HTTP in it, so the policy decisions it gates can be tested without sockets — the same split pkg/egressbroker draws between Broker and Proxy, for the same reason.
func NewRegistry ¶
NewRegistry returns a registry serving sessions beneath baseURL.
func (*Registry) Authenticate ¶
Authenticate resolves a session from the basic credential a sandbox presented. Every failure returns ErrUnauthenticated.
func (*Registry) CheckRestore ¶ added in v0.0.5
func (r *Registry) CheckRestore(rec SessionRecord) error
CheckRestore reports why Restore would refuse rec, or nil: everything it checks except the credential. A caller that must spend something to obtain the credential — mint a GitHub App token — asks first.
func (*Registry) Close ¶
Close revokes a session. It is idempotent, so a driver can call it from a defer without checking whether the run already ended.
func (*Registry) CloseForGrant ¶ added in v0.0.5
CloseForGrant closes the sessions grantID's credential feeds in leaseID — one grant of a lease revoked while the lease's other grants keep working (Task 20403) — and reports how many it closed.
func (*Registry) CloseForLease ¶ added in v0.0.5
CloseForLease closes every session whose upstream credential stands on leaseID, and reports how many it closed. It is how releasing a lease ends the sessions it fed when the lease no longer names them in its materials — one taken over from another process carries only grant ids.
func (*Registry) Known ¶ added in v0.0.5
Known reports whether id names a session this registry holds, live or not. It does not authenticate anything; see Options.Fallback for its one use.
func (*Registry) LeaseSessions ¶ added in v0.0.6
LeaseSessions returns the live sessions whose upstream credential stands on leaseID.
func (*Registry) Mint ¶
func (r *Registry) Mint(req MintRequest) (*Minted, error)
Mint creates a session and returns its one-time token.
func (*Registry) ReapExpired ¶
ReapExpired drops sessions past their TTL and returns how many went.
Authenticate already refuses an expired session, so this is hygiene rather than enforcement: without it the map grows for the life of the process.
func (*Registry) Renew ¶ added in v0.0.6
Renew moves a live session's deadline to ttl from now — 0 is DefaultSessionTTL, more than MaxSessionTTL is refused as Mint refuses it — and never backwards, recording the renewal in the Store when the session is durable. It returns the deadline afterwards.
It is how a workspace's session outlives session_minutes while its run does (Task 20422): the session is minted when the run is dispatched and has to authenticate the run's write-back push at its end, however long the run took. Whoever calls it decides the run is alive; the registry decides only that the session is. A session that is closed or unknown is ErrSessionNotFound, one already past its deadline ErrSessionLapsed — reviving it would race the reaper into serving a session it had written off.
func (*Registry) RenewDue ¶ added in v0.0.6
RenewDue renews id when RenewMargin(ttl) or less of its life remains, on the registry's clock, and reports whether it did and the deadline afterwards. A session that is closed or unknown is ErrSessionNotFound, one past its deadline ErrSessionLapsed, as for Renew.
func (*Registry) RenewalDue ¶ added in v0.0.6
RenewalDue reports whether id is open with RenewMargin(ttl) or less of its life left, on the registry's clock: what a renewer asks before anything costlier, such as whether the run is still alive.
func (*Registry) Restore ¶ added in v0.0.5
func (r *Registry) Restore(req RestoreRequest) (*Session, error)
Restore brings back a session another hub process minted, under the same id, token hash, scope and expiry, presenting the credential the caller re-derived. Its end is recorded in the Store like a minted durable session's.
It refuses a record it could not have minted — an unusable upstream, an allowlist or policy Mint would refuse — and a session already past its TTL, already live here, or whose token hash is not a SHA-256: a restored session is checked as strictly as a new one, because the record it comes from was read back from a database rather than produced by this process.
func (*Registry) Suspend ¶ added in v0.0.5
Suspend stops serving a session without ending it: it leaves the registry, but its record stays open and its OnEnd does not run, so the hub process that adopts its run can restore it. A hub stopping gracefully suspends its durable sessions rather than closing them, and a process whose lease another one took over suspends the sessions that lease feeds — the new holder serves them now. It reports whether the session was here.
func (*Registry) SuspendForLease ¶ added in v0.0.5
SuspendForLease suspends every durable session leaseID feeds and returns their ids, sorted. A session its Store holds no record of is closed instead, with reason: nothing could restore it, and a session that simply stopped being served would leave its audit trail without an end.
type RestoreRequest ¶ added in v0.0.5
type RestoreRequest struct {
// Record is the session as its Store recorded it.
Record SessionRecord
// Credential is presented upstream on the session's behalf: re-derived by
// the caller from the grant the session stands on, never read from the
// record, which has none. CredentialExpiresAt and Refresh are as on
// MintRequest.
Credential Credential
CredentialExpiresAt time.Time
Refresh RefreshFunc
// OnEnd is as on MintRequest.
OnEnd func()
// From names the hub process the session was taken over from, for the
// restored row.
From string
}
RestoreRequest is a session to bring back.
type Route ¶ added in v0.0.7
Route is a path prefix on the proxy's listener served by another handler.
type ScanFinding ¶ added in v0.0.7
type ScanFinding struct {
// Object is the object's id. Empty for a delta, whose id the proxy does
// not resolve: it would need the base, which may be on the forge.
Object string `json:"object,omitempty"`
// Type is commit, tree, blob or tag, or "delta".
Type string `json:"type"`
// Base is what a delta is a delta against: an object id, or the pack
// offset of an object in the same pack whose id is not known.
Base string `json:"base,omitempty"`
// Where says which part of the push carries it: "content", "message",
// "an inserted range", "a ref name", "a push option".
Where string `json:"where"`
// Path is where a tree in the same pack names the object: the full path
// when every tree above it is in the pack whole, or its name alone when
// only a delta inserting it was seen.
Path string `json:"path,omitempty"`
// PathPartial reports that Path is the object's name alone.
PathPartial bool `json:"path_partial,omitempty"`
// Commit is the commit that carries the object, when the pack says.
Commit string `json:"commit,omitempty"`
// Ref is the ref a finding in the command section is about, or the ref
// whose tip is named when the carrying commit could not be told.
Ref string `json:"ref,omitempty"`
// Tag is an annotated tag's name, for a finding in a tag object.
Tag string `json:"tag,omitempty"`
// Grant, Secret and Field are the matched value's provenance; Form is
// the encoding it was found in ("as delivered", "base64",
// "percent-encoded", "a line of a PEM block"). A shapes-mode finding
// carries the detector's label in Field and no grant.
Grant string `json:"grant,omitempty"`
Secret string `json:"secret,omitempty"`
Field string `json:"field"`
Form string `json:"form,omitempty"`
}
ScanFinding is one place a refused push carries a credential. It names objects and provenance, never the value.
type ScanLimits ¶ added in v0.0.7
ScanLimits bounds a scan. Zero fields take the defaults.
type ScanMode ¶ added in v0.0.7
type ScanMode string
ScanMode says what a push is scanned for.
const ( // ScanOff forwards a push's pack unread, as the proxy did before Task // 20436. ScanOff ScanMode = "off" // ScanValues matches the exact values the run was delivered, in their // common encodings. The default whenever the proxy runs. ScanValues ScanMode = "values" // ScanShapes is ScanValues plus pkg/redact's credential registry. ScanShapes ScanMode = "shapes" )
func ParseScanMode ¶ added in v0.0.7
ParseScanMode reads a configured mode. Empty is ScanValues: scanning is the default whenever the proxy runs, and switching it off has to be written down.
func StricterScanMode ¶ added in v0.0.7
StricterScanMode returns the stricter of a and b. It is how a project's own setting combines with the hub's: a project may tighten the scan and never loosen it.
type ScanSecret ¶ added in v0.0.7
type ScanSecret struct {
Value string
// Grant and Secret name the grant and the secret the run was delivered
// the value through. Either may be empty for a value no grant carried —
// the session's own token, or an egress session's.
Grant string
Secret string
// Field says which part of the delivery it is: "env API_KEY", "the
// kubeconfig's token for user ci", "the session's upstream token".
Field string
}
ScanSecret is one value a push must not carry, and where it came from.
Value is plaintext and stays in memory: it is never logged, persisted, audited or echoed, and nothing in this package renders it. The other fields are what a refusal names instead.
type Session ¶
type Session struct {
// ID identifies the session and is the username half of the basic
// credential the sandbox presents. Not secret.
ID string
// RepoPath is the "owner/name" the sandbox addresses, and the path the
// proxy serves this session under. Pinning it means a session minted for
// one repository cannot be replayed against another the same proxy happens
// to be serving.
//
// Empty on a scoped session, where RepoPatterns takes over. See scope.go.
RepoPath string
// RepoPatterns is an owner/name glob allowlist. When non-empty the session
// is *scoped* rather than pinned: it admits every repository the allowlist
// matches, which is what lets one session stand in for a user's PAT grant
// whose allowlist the hub cannot enumerate. See scope.go.
RepoPatterns []string
// Upstream is the real https clone URL — or, for a scoped session, the
// forge host base beneath which repository paths are resolved. Either way
// it never reaches the sandbox, and it is the only source of the host the
// credential is ever presented to.
Upstream string
// ForgeHost is the forge host the session's credential is good against,
// canonical: github.com, or a GitHub Enterprise Server host (Task 20423).
// The proxy serves the session on that host's route only (forge.go).
// Upstream is on this host, except for a scoped github.com session's,
// which is the hub's to point at github.com or, in a test, a stand-in.
ForgeHost string
// Policy is what this session may do.
Policy Policy
// Labels for the audit trail.
ProjectID string
TaskID string
ExecutorID string
Actor string
// RunID, GrantID and LeaseID say which run the session serves and which
// grant of which lease its upstream credential stands on (Task 20383): the
// lease it is closed with, and the record a restore finds it by. None is
// secret.
RunID string
GrantID string
LeaseID string
IssuedAt time.Time
// contains filtered or unexported fields
}
Session is one sandbox's brokered access to one repository.
func (*Session) AllowsRepo ¶ added in v0.0.2
AllowsRepo reports whether this session may address the given owner/name.
Deny-by-default in both modes: a pinned session admits its one repository, a scoped session admits only what its allowlist matches, and a session with neither admits nothing.
func (*Session) CloseReason ¶
CloseReason returns why the session was revoked, or "".
func (*Session) CredentialExpiresAt ¶ added in v0.0.5
CredentialExpiresAt reports when the forge stops honouring the credential the session presents now. Zero when it does not expire on a clock the hub knows.
func (*Session) Durable ¶ added in v0.0.5
Durable reports whether a Store holds a record of the session.
func (*Session) Refreshable ¶ added in v0.0.5
Refreshable reports whether the session renews its upstream credential.
func (*Session) RenewedAt ¶ added in v0.0.6
RenewedAt returns when Renew last moved the deadline, or zero.
type SessionRecord ¶ added in v0.0.5
type SessionRecord struct {
ID string
// TokenSHA256 is the hex SHA-256 of the session token.
TokenSHA256 string
RepoPath string
RepoPatterns []string
// Upstream is the forge URL or host base; it carries no credential (Mint
// refuses one with userinfo).
Upstream string
// ForgeHost is the forge host the session's credential is good against
// (Task 20423). A record written before hosts were recorded has none, and
// reads as github.com for a scoped session and its upstream's host for a
// pinned one — what it was minted for.
ForgeHost string
Policy Policy
ProjectID string
TaskID string
ExecutorID string
Actor string
RunID string
GrantID string
LeaseID string
IssuedAt time.Time
ExpiresAt time.Time
// RenewedAt is when Renew last moved ExpiresAt, zero for a session never
// renewed: what a restore bounds the recorded deadline from (Task 20422).
RenewedAt time.Time
}
SessionRecord is what a Store keeps of a session: everything Restore needs except the upstream credential, which whoever restores re-derives from the grant the session stands on.
type SessionStore ¶ added in v0.0.5
type SessionStore interface {
// SaveSession records a session this process now serves.
SaveSession(rec SessionRecord) error
// CloseSession records that a session this process served has ended,
// and why. A session another process took over is not this process's to
// close; an implementation fences the write on its own holder.
CloseSession(id, reason string, at time.Time) error
// RenewSession records a session's deadline moved to expiresAt by a
// renewal at renewedAt, fenced on the holder as CloseSession is.
RenewSession(id string, expiresAt, renewedAt time.Time) error
}
SessionStore persists the record of every Durable session and its end.
Errors are the implementation's to report: the registry cannot do anything useful with one beyond what it does — a session whose record could not be written is served all the same, and noted on its minted row.