git customs - proxy, mirror, scan and quarantine with human in the loop
  • Go 92.5%
  • HTML 3.8%
  • Shell 2.4%
  • Dockerfile 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Marcel M. Otte c1cea2ecda Add #24: remove a mirrored repo (transient tombstone)
Admin-only, irreversible removal of a mirror — DB row and on-disk bare
mirror — across both surfaces (`tullictl repo-rm`, web `DELETE /api/repos/
{id}` + a danger-zone button). Removal must never leave a row-less dir that
serves unscanned, so it flows through a transient tombstone:

- migrations/00005: nullable repos.deleted_at (+ partial index).
- Web records intent (MarkRepoDeleted) — no disk touch, per the §4
  single-writer model. The repo drops from ListRepos immediately.
- The gate goes inert BEFORE ensuring the mirror: Admit does a LookupRepo
  by URL and, if tombstoned, blocks ("repository is being removed") without
  serving or re-mirroring. This closes the removal window by state, not by
  prune speed; a lookup error fails open like the rest of the gate.
- The reconciler prunes dir-first: Mirror.Remove rm -rf's the bare dir
  (skips if a clone is in-flight, refuses any path outside the mirror root),
  and only on success DeleteRepo hard-deletes the row (FK cascade drops the
  children). Order is load-bearing — a skipped prune leaves the row
  tombstoned and inert, retried next sweep.
- Hard-delete (not a permanent soft-delete row) keeps re-mirroring a clean
  slate and avoids a deleted_at IS NULL filter through every query.
- Latency is sweep-paced but invisible (inert + hidden instantly); a
  wake-up nudge is left deferred-but-unblocked. Admin = root: no grace
  period, no undo. Removal is logged as an audit line (access_events can't
  anchor it post-cascade).

Tests: gate blocks a tombstoned repo before ensure (no re-mirror) +
lookup-error fails open; reconciler prunes dir then row, skips when busy,
no-op without a remover; Mirror.Remove root guard + in-flight defer; web
DELETE admin-only + 404; real-PG mark→list→cascade arc; e2e clone → remove
→ blocked → prune → re-mirror. Full -race suite green incl real PG.

Design captured in IMPLEMENTATION.md §5.

Closes #24

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:47:36 +00:00
certs Support corporate proxies: custom CA certs, trixie, apt mirror 2026-07-13 10:59:00 +02:00
cmd Add #24: remove a mirrored repo (transient tombstone) 2026-07-15 20:47:36 +00:00
internal Add #24: remove a mirrored repo (transient tombstone) 2026-07-15 20:47:36 +00:00
migrations Add #24: remove a mirrored repo (transient tombstone) 2026-07-15 20:47:36 +00:00
spike/stage0 Extend v2 design, add implementation plan, validate Strategy A 2026-07-08 19:50:06 +02:00
vendor Add stage 5: SSH transport (second door onto the hallway) 2026-07-09 16:53:38 +02:00
.dockerignore Support corporate proxies: custom CA certs, trixie, apt mirror 2026-07-13 10:59:00 +02:00
.gitignore Support corporate proxies: custom CA certs, trixie, apt mirror 2026-07-13 10:59:00 +02:00
demo.sh Wire full testing stack into docker compose; add WRAPUP 2026-07-10 13:07:24 +00:00
docker-compose.yml Support corporate proxies: custom CA certs, trixie, apt mirror 2026-07-13 10:59:00 +02:00
Dockerfile docker: build tullictl into the image 2026-07-15 16:38:49 +00:00
go.mod Add stage 5: SSH transport (second door onto the hallway) 2026-07-09 16:53:38 +02:00
go.sum Add stage 5: SSH transport (second door onto the hallway) 2026-07-09 16:53:38 +02:00
IMPLEMENTATION.md Add #24: remove a mirrored repo (transient tombstone) 2026-07-15 20:47:36 +00:00
LICENSE Add AGPL-3.0 license 2026-07-09 18:33:52 +02:00
prototype-spec-v2.md Add stage 6b-ui: browser UI + token→session login (revocable) 2026-07-10 11:13:20 +02:00
prototype-spec.md Add prototype spec (v1) and revised design (v2) 2026-07-06 20:15:21 +00:00
README.md Support corporate proxies: custom CA certs, trixie, apt mirror 2026-07-13 10:59:00 +02:00
sqlc.yaml Add stage 2: Postgres audit store (pgx + sqlc + goose) 2026-07-08 22:14:17 +02:00
WRAPUP.md Add #24: remove a mirrored repo (transient tombstone) 2026-07-15 20:47:36 +00:00

tulli

A caching git proxy for public open-source repos that mirrors, continuously scans, and quarantines external FOSS code — a customs house for everything your builds pull from the WWW. (tulli is Finnish for customs.)

Motivation: Yocto/Zephyr-style builds pull thousands of upstream repos; one-time human approval doesn't scale and misses post-approval compromise. tulli is the enforcement point — it owns the git server, so a quarantine is one check at the top of the fetch path.

Status

Early prototype, built in stages (see IMPLEMENTATION.md §5).

  • Stage 0 — Strategy A validated (spike/stage0/).
  • Stage 1 — walking skeleton: single read-only binary that lazily mirrors a public upstream on first fetch (single-flighted) and serves it via git-http-backend.
  • Stage 2 — persistence + audit: full §9 schema (goose), Postgres store (pgx + sqlc) recording repos + access_events. Postgres when TULLI_DATABASE_URL is set, else an in-memory store.
  • Stage 3 — the gate: a repo-wide quarantine aborts the clone with an ERR pkt-line (remote: tulli: repo QUARANTINED — …); a ref-level quarantine hides just that ref via uploadpack.hideRefs while the rest of the repo still clones.
  • Stage 4 — secret quarantine, end to end: the first fetch is served, then scanned asynchronously (gitleaks, fail-open-once); a HIGH secret retro-quarantines the affected ref(s); a fingerprint override lifts the block. A reconciler keeps on-disk hideRefs matching the DB (event-driven + a periodic safety sweep). No operator CLI/web yet — administration is the stage-6 web API.
  • Stage 5 — SSH transport: a second door onto the same hallway (git clone ssh://…). Blocks surface on the session's stderr (remote: …); ref-level quarantine flows through the exec'd git upload-pack for free.
  • Stage 6a — identity & access: an SSH public key or an HTTP token resolves to a user (with a role and human/robot kind) behind a pluggable provider seam (local DB now; LDAP/OIDC later). SSH requires a known key; HTTP fetches stay anonymous-friendly (a present-but-invalid token is a 401). Fetches are attributed to the resolved user in the audit trail. Verdicts also carry provenance — human quarantines live in a manual_blocks input table so the recompute sweep can't clobber them.
  • Stage 6b-core — the human-facing surface: a JSON API (internal/web, own port, off the git surface) is the single writer of human intents — block/unblock a ref or repo, override a finding, manage users/tokens/keys — with bearer-token auth and role-based access (viewer/reviewer/admin). cmd/tullictl is the CLI client. A block written through the API becomes a git-side quarantine on the next reconcile sweep. A browser UI (token→session login) is the next slice.

Known limitation (Strategy A)

A ref-level quarantine is enforced by hiding the ref, which is silent to the git client: a full clone just omits it; git clone -b <hidden> gets git's own "not found". Surfacing why a ref is hidden needs either the sideband success-message (Strategy B) or the web/triage UI — both deferred. A repo-wide quarantine does carry its reason in-band (the ERR message). See spec §3.3.

Build & run

go build -o tulli ./cmd/tulli
./tulli                     # listens on :8080, mirrors under ./data/mirrors

Point git at it (the transparent seam — spec §4):

git config --global \
  url."http://localhost:8080/repos/github.com/".insteadOf "https://github.com/"
git clone https://github.com/octocat/Hello-World     # fetched through tulli

…or address it directly:

git clone http://localhost:8080/repos/github.com/octocat/Hello-World

Config

Flag Env Default Meaning
-addr TULLI_ADDR :8080 listen address
-data TULLI_DATA ./data mirrors live in <data>/mirrors
-upstream-base TULLI_UPSTREAM_BASE https:// upstream base; a bare scheme, or a URL prefix for file:// test fixtures
-reconcile-interval TULLI_RECONCILE_INTERVAL 30s how often the reconcile safety sweep runs (catches out-of-process verdict/override writes)
TULLI_DATABASE_URL (unset) Postgres DSN for the store; unset ⇒ in-memory (non-persistent). Migrations run automatically on start.

Docker & the demo

docker compose up brings up Postgres + the proxy (the image bundles git and gitleaks). ./demo.sh then runs the spec §12 acceptance end to end — it needs only git and docker on the host (gitleaks, psql, and Postgres all run in containers):

./demo.sh                 # builds fixtures, brings up the stack, walks steps 14
docker compose down -v    # tear down

The demo: clones a clean repo (→ CLEAN); points a leaky tag at another repo and watches gitleaks retro-quarantine it (fail-open-once); shows the tag withheld while main still clones; then overrides the finding by fingerprint (a direct SQL INSERT, standing in for the stage-6 web API) and watches the reconcile sweep lift the block. TULLI_RECONCILE_INTERVAL is set to 3s in compose so the lift is quick.

Behind a corporate proxy (Zscaler etc.)

If you build or run tulli behind a TLS-inspecting proxy, its root CA has to be trusted or the image build (fetching gitleaks/trivy, the trivy vuln DB) and serving (tulli's HTTPS git fetches) fail on certificate verification. Drop your proxy's root CA — PEM, .crt extension — into certs/ and rebuild:

cp /path/to/zscaler-root.crt certs/
docker compose build          # or: docker build .

The Dockerfile installs everything in certs/*.crt into the system trust store in both build stages, so git, curl, trivy, and the Go binary all trust it — at build time and at runtime. Empty certs/ (the default) is a no-op. The dropped files are git-ignored so you don't commit your org's internal CA. See certs/README.md.

Two more knobs for locked-down networks (all optional, all in docker-compose.yml / --build-arg):

  • Explicit (non-transparent) proxy — route traffic through it with HTTP_PROXY/HTTPS_PROXY/NO_PROXY (build args for the image downloads, env for tulli's runtime git/trivy fetches). The proxy CA still comes from certs/.
  • Internal Debian mirror — if deb.debian.org is unreachable, point the base packages at your mirror: --build-arg APT_MIRROR=http://apt.corp/debian (it rewrites both …/debian and …/debian-security). Keep it HTTP where you can — Debian packages are GPG-signed, so the transport needs no TLS trust.

Test

go test ./...            # unit + integration
go test -race ./...      # with the race detector (single-flight is concurrency-critical)
go test -short ./...     # skip the network (real GitHub) integration test
  • Unit: internal/git (path resolve; single-flight/cache/retry/cancel via an injected clone fn; pkt-line + hideRefs against a real bare repo), internal/core (Admit / gate / fail-open-once enqueue), internal/verdict (blocking-set + recompute), internal/scan (gitleaks JSON parse + worker pipeline, via an injected runner — the binary is not required), internal/mirror (reconcile + sweep), internal/httpsrv (routing, block/error mapping) — with fakes.
  • Integration (internal/e2e): wires the real git/core/httpsrv stack behind an httptest server and drives actual git clone — over a local file:// upstream (deterministic) and, unless -short, a real GitHub clone.
  • Postgres (internal/store/pg): boots embedded-postgres, applies the schema, and runs the store's queries against a real PG. Skipped in -short; needs a writable $HOME (download cache) and network on first run.

Layout

cmd/tulli         main; wiring (server + scan worker + reconciler)
internal/git      path resolve, lazy mirror (single-flight), http-backend handoff,
                  ERR pkt-line + hideRefs + ref-containment (attribution)
internal/core     the hallway: Gatekeeper (Admit/FinishAudit) + seams
internal/verdict  blocking-set rule + recompute (findings + overrides → verdict)
internal/scan     Scanner seam + gitleaks adapter + async fail-open-once worker
internal/mirror   reconciler: DB verdicts → on-disk hideRefs (event + sweep)
internal/httpsrv  HTTP door onto the hallway
internal/sshsrv   SSH door onto the hallway (x/crypto/ssh)
internal/auth     identity & access: credential → user, pluggable provider seam
internal/web      JSON API + RBAC — the human-intent writer (cmd/tullictl client)
internal/store    backends: Mem (test fake) + pg/ (Postgres, sqlc)
migrations/       embedded goose schema (spec §9)
spike/stage0      throwaway proof of the CGI handoff (reference)

License

GNU AGPL-3.0. tulli is a network service, so the AGPL's §13 keeps improvements shared: anyone who runs a modified version and lets users reach it over the network must offer them the source. Using tulli imposes no obligation; only modifying-and-serving does.