Docs › Self-Hosted Server
Enterprise
Run a live, access-controlled multi-repo PullGuard dashboard
inside your own boundary, on your own domain (e.g.
https://pullguard.yourcompany.com) — with history, SSO and RBAC.
PullGuard's core promise is that your code never leaves your runners. A vendor-hosted dashboard would break that, so instead you run the server:
This is separate from the PullGuard licensing service — that only validates subscriptions and never receives scan data.
The server is configured entirely with environment variables:
| Variable | Required | Default | Purpose |
|---|---|---|---|
PULLGUARD_LICENSE_KEY | yes | — | Your Enterprise licence (offline key; validates locally with no network). The server refuses to start below Enterprise. |
PULLGUARD_SERVER_INGEST_TOKENS | yes | — | Comma-separated bearer tokens your CI presents on POST /api/scans. Rotate by adding a new one, cutting CI over, then removing the old. |
PORT | no | 8080 | Listen port. |
PULLGUARD_SERVER_HOST | no | 0.0.0.0 | Bind address. |
PULLGUARD_SERVER_DATA_DIR | no | ./data | Directory for the database. Mount a volume here to persist history. |
PULLGUARD_SERVER_MAX_BODY_BYTES | no | 5242880 | Hard cap on request body (oversize → HTTP 413). |
PULLGUARD_SERVER_INGEST_TOKEN_SCOPES | yes | — | Scoped ingest credentials: comma-separated <scope>=<token>, where a scope is org/repo or org/*. A token may file results only for the repositories it names — the repo a scan is filed under is chosen by the uploader, so an unscoped token could otherwise write a passing history onto a repository its holder cannot even read. A malformed entry fails the boot rather than becoming a silently unscoped token. |
PULLGUARD_SERVER_BASE_URL | with SSO | — | The external URL your reverse proxy serves this server on. Required when SSO is enabled — it is what the identity provider redirects back to. |
PULLGUARD_SERVER_POLICY_BLOCK_AT | no | major | Organisation-default severity at which a finding counts as blocking. A repository can be given its own threshold in Settings; one that blocks less is badged relaxed everywhere it appears. |
PULLGUARD_SERVER_POLICY_WARN_AT | no | moderate | Organisation-default severity at which a finding counts as a warning. |
PULLGUARD_SERVER_AUDIT_ANCHOR_FILE | no | — | Path to an external audit anchor — a tiny file holding the audit chain’s row count and head hash. Put it outside the data volume so tamper evidence does not live only in the database it protects. |
PULLGUARD_SERVER_QUERY_API | no | true | Set false to hard-disable the read-only /api/v1 surface. Worth knowing: ingest tokens also grant read, so an ingest-only deployment cannot otherwise turn it off. |
PULLGUARD_SERVER_ENFORCE_REPO_BAND | no | false | Hard-reject ingestion of a new repository once the licensed band is reached (HTTP 402). The default warns and stores the scan instead — dropping findings to enforce a licensing limit would lose security data. |
PULLGUARD_SERVER_WEBHOOK_ALLOWED_HOSTS | no | — | Exact hostnames outbound alerts may post to, comma-separated. Empty disables alerts entirely — with no allowlist the feature refuses to enable, rather than letting one compromised admin session point the server at any URL on your network. |
PULLGUARD_SERVER_SECRET_KEY | with alerts | — | 32-byte key that encrypts alert signing secrets at rest. Without it, alerts refuse to enable: the alternatives are a secret stored in the clear (a stolen database becomes a forged-alert kit) or unsigned alerts (a real alert becomes indistinguishable from a forged one). |
PULLGUARD_SERVER_WEBHOOK_ALLOW_PRIVATE | no | false | Allow alert delivery to private, loopback or link-local addresses — for an internal chat server on the same network. Off by default so an allowlisted hostname that resolves to a metadata-service address cannot turn an alert into a read of your cloud credentials. |
PULLGUARD_SERVER_SHARE_LINKS | no | false | Enable signed, expiring, read-only share links. A share link renders one report page to whoever holds the URL, with no login — a reasonable trade for one report and an external auditor, and never one to make on your behalf. Requires PULLGUARD_SERVER_SECRET_KEY as well; with the flag set and the key missing the server refuses to boot rather than starting with the capability quietly absent. |
PULLGUARD_SERVER_BACKUP_DIR | no | — | Directory the administrator backup action writes a consistent database copy into, with a sidecar carrying its checksum and the audit chain’s row count and head hash. Unset, the action refuses — a destination chosen by the request would be a write-anywhere primitive. Point it outside the volume you are backing up. The database is never streamed over HTTP. |
PULLGUARD_SERVER_RETAIN_SCANS | no | 200 | Scans retained per repo (bounds growth + history depth). |
The server fails closed: it won't start without a valid Enterprise licence and at least one ingest token — and, if SSO is enabled, not without a valid SSO configuration.
The server image is a private, Enterprise-only artifact, available two ways — use whichever fits your network policy.
Option A — private registry pull (ghcr.io/pullguard-dev/pullguard-server). With your Enterprise licence you receive a registry read token — authenticate once, then pull normally:
echo "$PULLGUARD_REGISTRY_TOKEN" | docker login ghcr.io -u pullguard-customer --password-stdin
docker pull ghcr.io/pullguard-dev/pullguard-server:latest
Option B — licence-gated HTTPS download. No GitHub identity or registry egress required — the same licence key you already hold (online pg_live_* token or offline pullguard_enterprise_* key) authenticates a direct tarball download:
curl -fL -H "Authorization: Bearer $PULLGUARD_LICENSE_KEY" \
-o pullguard-server.tar \
"https://pullguard.dev/api/server-image?version=latest"
# verify against the sha256 returned in the x-pullguard-sha256 response header
sha256sum pullguard-server.tar
docker load -i pullguard-server.tar
version accepts latest or an exact server semver (e.g. 0.4.1 — the server has its own version line, see below). Revocation is the licence itself — expire or revoke the key and the download stops; the endpoint never receives or stores scan data. Rate-limited per source IP.
Pin a version tag for change-controlled environments. Every published server image carries SLSA build provenance and an SBOM — verify with docker buildx imagetools inspect (Option A) or the sha256sum check above (Option B) before first run, exactly as with the scanner image.
Air-gapped? Either option produces a local image: mirror Option A on a connected machine (docker pull → docker save → transfer → docker load), or use Option B directly — the offline licence key validates locally, so the running server never contacts a registry or PullGuard. Don't have your registry token yet? Contact us with your licence org.
The server ships on its own version line, separate from the scanner — server releases are 0.x.y while the scanner is 1.x.y. Three tag channels are published for every server release:
| Tag | Moves? | Use it when |
|---|---|---|
sha256:… (digest) | never | Change-controlled and regulated environments. A digest is immutable and is what your admission controller should pin. |
0.4 (minor line) | on each patch in that line | The recommended production pin — you get patch and security fixes, never an unreviewed minor bump. |
latest | on every release | Evaluation and lab use only. It follows the newest published server image, so it can move under a running deployment. |
Pin, don’t float. The docker pull and ?version=latest commands above use latest because it is the shortest thing to paste for a first evaluation. For anything you run for real, replace it with the minor line (:0.4) or a digest — the same guidance as the scanner image.
For the current server version and what changed in it, see the release notes; server releases are called out there alongside the scanner. Your licence covers every version on the line you are entitled to — upgrading is a re-pull plus a restart, and the database is migrated in place on first boot.
export PULLGUARD_LICENSE_KEY=pullguard_enterprise_yourorg_...
export PULLGUARD_SERVER_INGEST_TOKENS=$(openssl rand -hex 24)
docker compose up -d
# dashboard on http://localhost:8080 (put your TLS-terminating proxy in front)
A Dockerfile, docker-compose.yml, and a Helm chart ship with the server package. For Kubernetes, create a docker-registry pull secret from your registry token (imagePullSecrets in the chart values), store the licence and ingest tokens as a secret, and helm install the chart.
Add two inputs to your PullGuard GitHub Action — after each scan it uploads the results to your server (best-effort; a server hiccup never fails your build):
- uses: pullguard-dev/pullguard-action@v1
with:
server-url: https://pullguard.yourcompany.com
server-token: ${{ secrets.PULLGUARD_SERVER_TOKEN }}
Set PULLGUARD_SERVER_TOKEN to one of your ingest tokens. The Action uploads only the finding report — never source.
/) — every repo at a glance: project count, failing repos, open-findings and critical/major totals, plus a sortable table. Click a row to drill in./repos/<org>/<name>) — grade, score, findings over time, with drill-down to file:line.These reuse the same renderers as the air-gapped HTML report — see Reports & Dashboard.
Disposition findings per finding, per line from the dashboard: mark a finding Acknowledged, False positive, Won’t fix, or Accept risk (until a date), with a reason. The buttons are inline on every finding row — on the repo dashboard and on the triage view — and each one is labelled with the decision it records, so it is always clear what is being asserted. The triage view is a full review surface — a “% triaged” KPI, per-finding effort and estimated cost, code owner (from git-blame), and faceted filters by severity, category and status.
maintainer role (or above); viewers see dispositions but can’t change them. Deployments without an identity provider can opt in to triage with PULLGUARD_SERVER_OPEN_TRIAGE=true (v1.5.1) — decisions stay audit-logged (recorded as unauthenticated), and the server refuses to start if the flag is combined with SSO.PULLGUARD_SERVER_RESOLVE_AFTER_SCANS requires several consecutive clean scans before anything counts as fixed.maintainer role still sees every action button, greyed out, with a one-line reason and a sign-in link — never a blank space that could be mistaken for a missing feature.server.pullTriage: true in a repo’s .driftrc.yml, a scan fetches that repo’s decisions at start and honours them — so a false positive dismissed on the dashboard stops blocking the merge, without anyone editing a file twice. It is off by default, it only activates when the server URL and token are already configured, and a committed .pullguardignore entry always wins. Security findings keep their protection: a dismissal that would hide one is refused, and a reasoned false-positive override stays fully visible — it only lifts the block. If the server is unreachable the scan simply proceeds with no decisions applied and says so.
This is distinct from scanner-side .pullguardignore (which suppresses a finding on
future scans, and never for security-category findings). Triage is the
review/governance layer on the results you already have.
Settings writes need an admin session or a managed token carrying admin +
agent-write. Without single sign-on there are no sessions, so bootstrap the first credential from the host
that owns the data directory — pullguard-server token mint --label "bootstrap admin" --scopes "your-org/*" --admin
— printed once, never stored, expiry bounded, audit-logged; then mint narrower tokens with it and revoke it.
A Settings area (linked from the portfolio header) lists every repository the server holds, with a Hide / Unhide toggle and a short note. Hiding a decommissioned, archived or experimental repository keeps it out of the portfolio your leadership team reads — without losing anything.
admin session and are written to the same tamper-evident audit log as a triage decision. Unlike triage, there is no unauthenticated mode: removing a repository from the org view always carries an identity.A read-only JSON API lets you surface repo grades, security posture, compliance status, finding trends and triage state inside your own control-plane / ops UI — without leaving the tools your team already uses.
GET /api/v1/portfolio — all repos + roll-up.GET /api/v1/repos/<id>/scans — per-repo scan history.GET /api/v1/repos/<id>/scans/<scanId>/findings — findings, each with its triageStatus.GET /api/v1/repos/<id>/triage — that repo’s live decisions (this is what a CI scan reads when server.pullTriage is on). Authorized by the same per-repo scopes as ingestion — a token can only read the repositories it is scoped for.GET /api/v1/health — reachability.
Authenticated with a read-only token (PULLGUARD_SERVER_READ_TOKENS, GET-scoped — it cannot mutate data) and an explicit CORS allowlist (PULLGUARD_SERVER_CORS_ORIGINS; no wildcard). Results only — never source.
GET /api/v1/openapi.json returns an OpenAPI 3.1 description of
every operation — parameters, response shapes, status codes and which credential each one accepts.
It is generated from the routes the server actually registered, so it cannot describe an endpoint that
does not exist, nor omit one that does. Point any client generator at it. The document is public and
unauthenticated because it contains no repository, no finding, no credential and no operator name.
Stability: /api/v1 is additive-only. We may add endpoints,
optional query parameters and response fields; we will not remove or rename a field, change its type,
change a status code, or tighten validation on a request that is accepted today. A breaking change would
ship on a new path (/api/v2/…) with /api/v1 still served, so your
integration upgrades on its own schedule.
An environment read token (PULLGUARD_SERVER_READ_TOKENS) is
the bootstrap path: unscoped, no expiry, changed by a deploy. A managed token
issued in Settings → Tokens is always scoped to named repositories, always carries an expiry of
1–365 days, and can be rotated or revoked in one click. Prefer the managed, scoped kind: listings are
filtered to its scope, a request for a repository outside it is refused and audited, and a leak is worth
less. Some reads refuse an unscoped token outright — /api/v1/repos/<repo>/triage
returns your team’s decisions, so it is authorized by scope rather than by holding any accepted token.
CORS is an exact-origin allowlist
(PULLGUARD_SERVER_CORS_ORIGINS) — no wildcard, no origin reflection.
Rate limits apply only to failed authentication, per IP, so a
healthy integration is never throttled. Paging is a clamped
limit with a pre-cap total: an over-large limit returns the cap rather than an
error, and there is no cursor, so one call can never walk an entire estate.
BASE=https://pullguard.internal
AUTH="Authorization: Bearer $TOKEN"
curl -s -H "$AUTH" $BASE/api/v1/whoami
curl -s -H "$AUTH" $BASE/api/v1/portfolio
curl -s -H "$AUTH" "$BASE/api/v1/repos/acme%2Fweb/scans?limit=20"
curl -s -H "$AUTH" "$BASE/api/v1/repos/acme%2Fweb/scans/scan_42/findings?severity=critical"
curl -s -H "$AUTH" $BASE/api/v1/repos/acme%2Fweb/gate-status
curl -s -H "$AUTH" "$BASE/api/v1/repos/acme%2Fweb/diff?format=csv" -o diff.csv
curl -s -H "$AUTH" $BASE/api/v1/repos/acme%2Fweb/triage
curl -s $BASE/api/v1/openapi.json > pullguard-api.json
A coding agent connected through pullguard mcp --server reads findings out of the box. It can
also be allowed to act — but only if an administrator ticks two boxes on the Tokens page for
that specific credential: agent write (record triage decisions within the
token’s scope) and, on top of it, admin (hide a repository, set gate
and SLA policy, issue or revoke tokens).
agent:<client>
alongside the administrator who granted the access.
Every credential your CI presents used to live in an environment variable, so issuing one to a
new team was a deploy and revoking one was an emergency deploy. Settings → Tokens
(/settings/tokens) lets an administrator do both from the dashboard.
org/repo or org/*) and can act on nothing else — for uploads and for reads. There is no unscoped managed token.PULLGUARD_SERVER_INGEST_TOKEN_SCOPES for those, and manage the rest here.The Settings page also carries two per-repository controls, both administrator-only and both audited.
PULLGUARD_SERVER_POLICY_BLOCK_AT, default major, and PULLGUARD_SERVER_POLICY_WARN_AT, default moderate) and an administrator can override the pair for one repository. Every change appends a versioned history row — who changed it, when, from what — readable by any signed-in viewer. Gate conditions include finding-severity floors, security and AI-surface counts, score and grade, and (server 0.4.2+) partial_scan_max, which refuses a scan that hit a file cap — a report without coverage data counts as partial.ageDays, ageBucket and overSla on every finding.
/repos/<org>/<name>/diff answers the Monday-morning question directly: what appeared,
what was fixed, what came back, what changed severity, and which triage decisions were recorded
between any two scans (defaulting to the previous one and the latest). The actionable rows carry the same
labelled triage buttons, and the whole comparison exports as JSON or CSV.
“Came back” comes from the server’s own cross-scan record, so a regression is reported as a regression rather than as a new finding. Nothing about a diff is stored — it is derived from the scans you already have, so it can never disagree with them.
org/name, results are grouped by organisation and keyboard-navigable, and the repository you are looking at is pinned at the top. It works without JavaScript (the panel always contains a plain link to the portfolio), and it carries names only — no grades or counts — so a page never leaks the shape of a project to someone who cannot open it.GET /healthz. It answers as long as the process is up and deliberately does not touch the database — a liveness probe that depends on a working query turns a degraded server into a restart loop.GET /readyz. It runs a real transaction against the store (proving it is writable and migrated) and re-checks the licence expiry, so an instance that stops being writable or entitled is pulled out of rotation with a 503 instead of quietly serving failures. Both probes are rate-limited and return a constant body — no version, no paths, nothing an unauthenticated caller can learn from.PULLGUARD_SERVER_BASE_URL to the external URL (required when SSO is enabled — it is what the IdP redirects back to). Every response carries Cache-Control: no-store, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY and a per-request nonce-based Content-Security-Policy, so the dashboard cannot be framed, cached by a shared proxy, or made to run an injected script.PULLGUARD_SERVER_AUDIT_ANCHOR_FILE at a path outside the data volume. The audit chain and its counters otherwise live in the same file they protect. Then pullguard-server audit verify re-hashes the whole chain against that external witness and exits non-zero if anything has been altered or removed — a command an operator can run on a stopped server, or from cron.PULLGUARD_SERVER_QUERY_API=false hard-disables the read-only API even when ingest tokens exist; PULLGUARD_SERVER_ENFORCE_REPO_BAND=true makes the licensed repository band a hard reject on a new repository rather than a warning.Every route the server exposes. A build-time check fails if one ships without a row here.
| Route | Auth | Purpose |
|---|---|---|
GET /healthz | none | Liveness. |
GET /readyz | none | Readiness (store writable + licence valid). |
POST /api/scans | scoped ingest token | Upload a scan result. |
GET / | viewer | Portfolio dashboard. |
GET /repos/<org>/<name> | viewer | Single-repository dashboard. |
GET /repos/<org>/<name>/diff | viewer | What changed between two scans. |
GET /triage/<org>/<name> | viewer | Interactive triage view. |
GET /api/triage/<org>/<name> | viewer | Triage summary + records as JSON. |
POST /api/triage/<org>/<name> | maintainer + CSRF | Record or clear a decision (single or bulk). |
GET /settings | viewer (writes: admin) | Visibility, gate policy and SLA per repository. |
POST /api/v1/settings/repos/<repo> | admin + CSRF | Hide/unhide a repository. |
GET /api/v1/settings/repos/<repo>/gate-policy | viewer | Policy in force + its versioned history. |
POST /api/v1/settings/repos/<repo>/gate-policy | admin + CSRF | Set or clear the per-repository thresholds. |
POST /api/v1/settings/repos/<repo>/sla-policy | admin + CSRF | Set the per-severity SLA budgets. |
POST /api/v1/settings/repos/<repo>/triage-classes | maintainer + SSO | Record a class decision. |
POST /api/v1/settings/repos/<repo>/triage-classes/<id>/revoke | maintainer + SSO | Withdraw a class decision. |
GET /settings/tokens | admin | Managed API tokens. |
POST /api/v1/settings/tokens | admin + CSRF | Issue a token (secret shown once). |
POST /api/v1/settings/tokens/<id>/rotate | admin + CSRF | Rotate, with a bounded grace window. |
POST /api/v1/settings/tokens/<id>/revoke | admin + CSRF | Revoke immediately. |
GET /api/v1/quality-gates | viewer | List gates and assignments. |
POST /api/v1/quality-gates | admin + CSRF | Create or update a gate. |
DELETE /api/v1/quality-gates/<id> | admin + CSRF | Delete a gate. |
POST /api/v1/quality-gates/assign | admin + CSRF | Assign a gate to a repo pattern. |
DELETE /api/v1/quality-gates/assign/<pattern> | admin + CSRF | Remove an assignment. |
GET /api/v1/repos/<repo>/gate-status | read token | Gate verdict + the policy in force. |
GET /api/v1/repos/<repo>/diff | read token | Scan-to-scan diff (?format=csv). |
GET /api/v1/openapi.json | none | The OpenAPI 3.1 description of the whole /api/v1 surface. Public: paths and shapes, never your data. |
GET /api/v1/whoami | read token | What the presented credential may do — kind, scopes, agent capabilities. No secret, no token id. |
POST /api/v1/settings/tokens/<id>/capabilities | admin + CSRF | Grant or withdraw a token’s agent access. The one token operation an agent credential can never perform. |
GET /api/audit | admin | The hash-chained audit log (?format=csv). |
GET /settings/alerts | admin | Outbound alert destinations and the undelivered queue. |
GET /api/v1/settings/alerts | admin | The same inventory as JSON. Never returns a signing secret. |
POST /api/v1/settings/alerts | admin + CSRF | Add a destination. HTTPS only, host must be allowlisted, off until enabled. |
POST /api/v1/settings/alerts/<id>/delete | admin + CSRF | Remove a destination. |
POST /api/v1/settings/alerts/<id>/test | admin + CSRF | Send one test alert down the real delivery path. |
POST /api/v1/settings/alerts/dead-letters/<id>/retry | admin + CSRF | Re-attempt an undelivered alert (re-signed, never replayed). |
GET /audit | admin or auditor | The audit viewer: filter, verify the chain, export CSV/JSON. Read-only. |
GET /api/v1/audit/verify | admin or auditor | The chain verdict as JSON, for a compliance script. |
GET /api/v1/repos/<repo>/evidence | admin or auditor | Compliance evidence pack for one repository over a period (?from=&to=&format=html|json). |
GET /api/v1/evidence | admin or auditor | The same pack across the portfolio. |
POST /api/v1/repos/<repo>/assignments | maintainer + CSRF | Assign a finding to a person or a team, or unassign it. Routing only — it never suppresses a finding, changes a severity, or feeds a Quality Gate. |
GET /api/v1/me/findings | viewer (session only) | Everything assigned to you or your teams. No subject parameter: “mine” must mean the person holding the session. |
GET /me | viewer (session only) | The same worklist as a page. |
GET /settings/teams | admin | Settings → Teams: per-team repository visibility. |
GET /api/v1/settings/teams | admin | The configured team scopes as JSON. |
POST /api/v1/settings/teams | admin + CSRF | Set or clear a team’s repository scope. Audited, and never reachable by an agent credential. |
GET /api/v1/settings/repos/<repo>/retention | admin | The scan-history retention policy and its dry-run count. Pass ?maxAgeDays=/?maxScans= to price a policy before setting it. |
POST /api/v1/settings/repos/<repo>/retention | admin + CSRF | Set, clear or immediately apply a retention policy. Audited. |
POST /api/v1/settings/backup | admin + CSRF | Write a consistent database copy plus sidecar into the configured backup directory. Refuses when it is unset. There is deliberately no download route. |
GET /api/v1/share-links | admin | The inventory of live read-only links. The token and its hash are never returned. |
POST /api/v1/share-links | maintainer + CSRF | Mint a signed, expiring link to one report page of one repository. Off unless share links are enabled. |
POST /api/v1/share-links/<id>/revoke | maintainer + CSRF | Revoke a link. It stops working immediately, not at its expiry. |
GET /shared/<token> | the token | The shared view: one read-only report page, no controls, no navigation, noindex. |
GET /catalog | viewer | What PullGuard checks. |
GET /auth/login · POST /auth/logout | — | SSO sign-in and CSRF-checked sign-out (registered only when SSO is configured). |
Every finding carries an Owner column with two facts side by side:
the git-blame author the scan recorded, and the person or team it is assigned to. An assignee is
user:<sso-subject> or team:<idp-group> — the prefix is required, because
a subject and a group are both opaque strings from the same directory, and guessing would silently route a
finding to a group nobody is in.
An assignment is routing, and nothing else. It never suppresses a finding, never changes a severity, is never a filter that removes a row from a machine-readable surface, and is never an input to a Quality Gate. That is precisely why it is allowed on deployments where a merge-blocking decision is not. Every assignment is recorded in the same tamper-evident audit chain as a triage decision.
The owner suggestion is pre-filled, never applied. It comes from the scan itself — git blame, or the CODEOWNERS owner the scanner resolved when you opted in. This server has no repository access and never reads a CODEOWNERS file: if the scan did not carry ownership, there is no suggestion. Blame names whoever last touched the line, which after a reformat is a stranger, so a human presses the button.
Notifications are off. Assignment sends nothing unless you have alerts
configured and subscribe a destination to the assigned trigger. /me is your worklist
across every repository you can see.
A 400-repository portfolio is unreadable to the team who owns eight of them. Settings → Teams scopes an identity-provider group to a set of repositories — the same group strings your role map is already keyed on, so there is no membership list here to go stale.
Administrators are never scoped — they configure the scopes. Every change is audited.
The default is keep everything, and an upgrade changes nothing. Retention is opt-in per repository. It deletes stored scan payloads beyond the window you set — and it may never delete the latest scan of a repository, a triage decision, a first-seen date, an assignment, or an audit row. A dry-run count is shown before you commit, and recorded in the audit chain alongside the policy that produced it, so the decision and its blast radius are one record.
The backup action writes a complete, consistent copy of the database — taken with SQLite’s
VACUUM INTO, because a plain file copy of a live database is torn — into the directory you
configured, with a sidecar carrying its checksum and the audit chain’s row count and head hash. After a
restore, pullguard-server audit verify checks the chain against that sidecar: a
self-consistent chain proves only that the copy is coherent, and a tampered copy is coherent too.
There is no download route, and there will not be one. The file is every finding, decision, actor and audit row on the deployment. An endpoint that returned it would make one stolen administrator session equal to the whole dataset in a single request.
The person who has to see a result is often not somebody you will provision an SSO seat for: an external auditor, a client’s security reviewer, a board member reading one quarter. The alternatives are a screenshot (unverifiable, and now a document nobody can revoke) or an account for a one-off read.
Off by default, behind two separate deployment-time acts. A maintainer mints a link for one report page of one repository, with a lifetime between an hour and thirty days.
The shared page renders read-only: no triage or settings controls, no repository switcher, no API links, no
navigation out of it, a banner naming the expiry, and search-engine noindex headers. A shared diff
withholds triage actors — “who dismissed this” names an employee to a reader outside the
organisation, and the audit log answers that question, under its own guard, to somebody inside it.
PullGuard posts nowhere by default. Outbound alerts stay off until an administrator adds a destination in Settings → Alerts, and a destination cannot be added at all until you have named the hosts this server is allowed to reach.
A destination is a Slack, Microsoft Teams or plain-JSON webhook, scoped to one repository, an organisation, or all of them, and subscribed to the events you care about: a new critical security finding, a finding going over its SLA budget, a finding reopening, the quality gate flipping between pass and fail, or a new class triage decision. Every one of those is a change rather than a state, so a long-standing problem does not re-announce itself on every upload and train your team to mute the channel.
An alert carries the repository, the finding type, its severity, file:line and a
link back to your dashboard — never source code, and never a
finding’s message. It lands in a chat room whose membership PullGuard does not
control, so it carries only enough to let somebody who is authorised go and look.
Every body is signed HMAC-SHA256 with a per-destination secret, so your receiver
can prove an alert came from your server and not from anyone who learned the URL.
An outbound feature on a security scanner is a way into your network if it is built carelessly, so this one is deliberately narrow:
example.com would also admit evil-example.com, so you name the host you mean.30x is a delivery failure — following one would let the destination send your server anywhere.
Every triage decision, policy change, credential issuance, login and failed alert delivery is
appended to one hash-chained log. GET /audit is that log as a page: filter by who,
what, which repository and when; export the filtered set as CSV or JSON; and read the chain’s
integrity in plain words at the top — whether the hashes verify, whether rows appear to be
missing, and whether an external anchor is configured. Actors that are coding agents are badged,
so an agent is never mistaken for a person. Nothing on the page can change the log.
Because it cannot write, it is the one privileged surface that can safely be given to a
read-only auditor role: read the dashboards and
the audit log, export evidence, and nothing else. It sits sideways on the ladder rather
than above maintainer — a maintainer does not gain the audit log by being a
maintainer, and an auditor never satisfies a write guard anywhere on the server. Map an IdP group
to it exactly as with the others.
An auditor rarely asks what your scanner finds today. They ask what you knew during the period, what you decided, who decided it, and whether the record has been edited since. Export a pack for one repository or the whole portfolio, over any date range, as a self-contained HTML document that opens with no network access — or as JSON for your GRC tool.
Each pack holds, per repository: the compliance framework rollups recorded on the scans in that period — the worst as well as the end-of-period one, because a control that failed for two months and passed on the last day did not have a passing quarter — every per-finding and class decision with its reason, actor and expiry, resolved and reopened counts with mean time to resolve, the quality-gate verdict history, an extract of the audit log, the versions in use, and the chain verdict at the moment of export.
Two things it deliberately is not. It is evidence supporting an assessment, never a compliance claim — a scanner can show a record, it cannot certify a control, and the pack says so on its first page. And it never re-assesses anything: this server holds no analysis engine, so every status in the pack is one your scanner emitted at the time and this server stored. Taking a copy is itself recorded in the audit log. No source code is included.
By default (PULLGUARD_SSO_MODE=none) the dashboard is protected by your network
boundary / reverse proxy, and machine ingestion is always token-authenticated. To require
human login and role-gating, enable OIDC or
SAML 2.0:
PULLGUARD_SSO_MODE=oidc plus your issuer, client ID and secret, and the server's base URL (Authorization-Code + PKCE).PULLGUARD_SSO_MODE=saml plus your IdP metadata / certificate and entity IDs. Login is SP-initiated; an unsolicited assertion is refused. From server 0.4.2, PULLGUARD_SAML_UNSOLICITED_REDIRECT=true (default off) answers the identity provider's launcher tile with a single redirect into a normal login instead of an error — the assertion is never parsed or used, the redirect target is fixed, and the refused attempt stays in the audit log.viewer < maintainer < admin, plus the read-only auditor) via PULLGUARD_SSO_ROLE_MAP. Access is deny-by-default: an authenticated user matching no role gets none.Sessions are server-side with idle and absolute expiry and CSRF protection. Full SSO setup ships as docs/sso.md in the server package.
Since v1.4.0, Enterprise license keys can carry a signed repository-count band, and the server meters distinct-repo ingestion against it:
The band is part of the signed key, so it validates locally like the rest of the licence — still no call home, still fully air-gapped.
The scanner already emits pullguard.sarif and a self-contained HTML report on
every run — pipe those into your existing Grafana / SIEM / DefectDojo, or just browse
the offline report. See Reports & Dashboard. The
self-hosted server is the upgrade when you want a live, access-controlled, multi-repo view.
Questions or an Enterprise trial? hello@pullguard.dev.