# Drone 2.24 + Syft, Grype, Trivy: offline setup guide

This bundle installs **Drone CI 2.24.0** with an offline **software composition
analysis (SCA)** stage built on **Syft** (SBOM), **Grype** and **Trivy**. It
targets an **air-gapped RHEL 9.6 / x86_64** network and fits the company
toolchain:

```
Jira ─ Gitea ──webhook──▶ Drone server 2.24.0 ◀──RPC── Drone Docker runner 1.8.5
                               │                         │ (Podman socket)
                               │                         ▼
                               │        clone ▶ scanner-data ▶ sbom-syft ▶ grype ▶ trivy-fs
                               │              ▶ trivy-image ▶ security-gate ▶ evidence
                               ▼
                         SonarQube / openQA / ZAP stages (other bundles)
```

Nothing in this setup talks to the Internet. All images are pinned by digest,
all binaries by SHA-256, and the vulnerability databases ship inside the
scanner image. Every scanner call passes explicit "no update, no telemetry"
flags.

---

## 0. What you get

| Component | Version / pin | Delivered as |
|---|---|---|
| Drone server | `drone/drone:2.24.0` (amd64 `sha256:0db82b62…`) | `images/drone-server-2.24.0-amd64.tar` |
| Drone Docker runner | `drone/drone-runner-docker:1.8.5` (`sha256:77097e06…`) | `images/drone-runner-docker-1.8.5-amd64.tar` |
| Clone image | `drone/git:latest` pinned to `sha256:c0f7ca05…` (2023-01-04) | `images/drone-git-amd64.tar` |
| Scanner image | UBI 9.8 minimal + Syft 1.51.1 + Grype 0.118.0 + Trivy 0.74.0 + DBs | `images/drone-scanners-<date>-amd64.tar` |
| Grype DB | schema v6, built 2026-09-11 | inside the scanner image |
| Trivy DB, Java DB, checks bundle | updated 2026-09-11 | inside the scanner image |
| Drone CLI | 1.9.0 | `downloads/drone-cli_1.9.0_linux_amd64.tar.gz` |
| Scanner binaries | release tarballs | `downloads/` (for native use on hosts) |

| Path | Purpose |
|---|---|
| `config/versions.env` | Every version, digest and checksum, all in one file |
| `config/scanner-image.env` | Scanner image tag, ID and data dates (written by `05`) |
| `config/drone-server.env.example` | Server settings template → `/etc/drone/server.env` |
| `config/drone-runner.env.example` | Runner settings template → `/etc/drone/runner.env` |
| `config/scanners/` | Admin-managed Syft/Grype/Trivy configs + exception files (baked into the image) |
| `containers/Containerfile.scanners`, `containers/drone-scan` | Scanner image recipe and its wrapper CLI |
| `drone/.drone.yml` | Pipeline template to copy into repositories |
| `systemd/*.container` | Podman Quadlet units for the server and runner |
| `scripts/00-download.sh` | **Connected builder:** download and verify everything |
| `scripts/05-build-scanner-image.sh` | **Connected builder:** build the scanner image offline and smoke-test it |
| `scripts/99-package.sh` | **Connected builder:** tarball + `SHA256SUMS` |
| `scripts/10-load.sh` | **Air-gapped host:** verify, load images, run scanner self-tests with `--network=none` |
| `scripts/15-push-registry.sh` | **Air-gapped (optional):** push images to the internal registry |
| `scripts/20-install.sh` | **Air-gapped host:** install server / runner / CLI as systemd services |
| `scripts/30-verify-offline.sh` | **Any host:** full end-to-end proof on an isolated network |
| `demo/` | Test fixtures used by `30-verify-offline.sh` |
| `metadata/`, `evidence/` | Provenance, logs, verification evidence |

The script numbering matches the other `/work/acb` bundles: `00`/`05`/`99` run on
the connected builder and `10`–`30` run inside the air gap.

---

## 1. Before you start: licensing

`docker.io/drone/drone:2.24.0` is the **Drone Enterprise Edition** build. Per
the `LICENSE` file in the v2.24.0 source, it is covered by the **Drone
Non-Commercial License**. Commercial use is limited to a 32-day trial unless
one of the waivers applies:

* **Small business:** under US$5M annual gross revenue *and* under US$5M total
  debt/equity funding, counting affiliates, or
* **Low usage:** fewer than 5,000 pipelines executed in the preceding year.

With the Gitea provider the software enforces no build limit (the Gitea trial
has `Builds: 0` in `service/license/load.go`), so nothing stops working when the
trial ends. Compliance is your responsibility. The Apache-2.0 *Community
Edition* (`go build -tags "oss nolimit"`) removes repository secrets, cron jobs
and remote runners, so it cannot replace this setup. **Confirm eligibility with
whoever owns licensing, or buy a license from Harness, before production use.**

---

## 2. Requirements

**Connected builder** (any Linux x86_64): `podman`, `skopeo`, `jq`, `curl`, `tar`,
about **15 GB** free (DB staging, image, package).

**Air-gapped hosts:** RHEL 9.6 x86_64 with the `container-tools` packages
(`podman` 5.4, `skopeo`). Also `python3` and `git` if you run `30-verify-offline.sh`.

| Host role | CPU / RAM | Disk | Notes |
|---|---|---|---|
| Drone server | 2 vCPU / 2 GB | 10 GB + build logs | SQLite in `/var/lib/drone` (PostgreSQL optional) |
| Runner | 4 vCPU / 8 GB per 2 concurrent builds | **≥ 25 GB** | The scanner image is 5.2 GB, and each scan step writes to a container layer |

**Network flows inside the air gap:**

| From | To | Port | Why |
|---|---|---|---|
| Users' browsers | Drone server | 443 (or 8080) | UI, OAuth login |
| Drone server | Gitea | 443/3000 | OAuth token exchange, API, webhook setup |
| **Gitea** | **Drone server** | 443/8080 | **Webhooks** (`/hook`) |
| Runner | Drone server | 443/8080 | RPC polling (outbound only) |
| Pipeline step containers | Gitea | 443/3000 | `git clone` |
| Runner (optional) | Internal registry | 443 | Pulling images if you use a registry |

---

## 3. On the connected builder

```bash
cd devsecops-drone
scripts/00-download.sh              # images, binaries, RPMs, fresh Grype/Trivy data
scripts/05-build-scanner-image.sh   # builds localhost/devsecops/drone-scanners:<date>
scripts/30-verify-offline.sh        # optional here (needs docker.io/gitea/gitea:1.22 loaded)
scripts/99-package.sh               # -> ../devsecops-drone-offline-2.24.0-<date>-linux-amd64.tar.gz
```

What the scripts check:

* `00` stops if an upstream tag now points to a different digest than
  `config/versions.env`. It also stops if a tarball's SHA-256 differs from the
  pin or from the vendor's checksums file. Trivy pulls its DBs as OCI artefacts
  and verifies each digest. The Grype DB is checked against the SHA-256 in the
  official listing.
* `05` builds with `--network=none --pull=never`. The UBI RPMs are GPG-checked
  against the Red Hat key before installation. The build finishes by running
  `drone-scan smoke` with no network: a vulnerable fixture must be blocked and a
  clean one must pass.
* `99` excludes `build/` (about 5 GB of staging). The data travels inside the
  scanner image archive.

Move the `.tar.gz` **and** its `.sha256` through your approved transfer path.

---

## 4. Inside the air gap

### 4.1 Verify and unpack

```bash
sha256sum -c devsecops-drone-offline-2.24.0-*-linux-amd64.tar.gz.sha256
tar -xzf devsecops-drone-offline-2.24.0-*-linux-amd64.tar.gz -C /opt
cd /opt/devsecops-drone
```

### 4.2 Load images (server host **and every runner host**)

```bash
sudo scripts/10-load.sh            # podman (RHEL default)
```

The script checks `SHA256SUMS`, loads each archive and compares the image ID
with the one recorded at download time. It tags the scanner image
`localhost/devsecops/drone-scanners:stable`. Then, with **`--network=none`**, it
runs the scanner self-test and a real SBOM + Grype + Trivy scan of the bundled
UBI image archive. It must end with `PASS`.

> Podman and Docker have **separate image stores**. The runner below uses
> Podman's socket, so load with podman. If a runner host uses Docker CE, run
> `scripts/10-load.sh docker` there instead.

With more than one runner, use an internal registry instead of loading on every
host (see [§6](#6-optional-internal-registry)).

### 4.3 Prepare Gitea

1. **Gitea must be reachable from containers under its `ROOT_URL`.** Drone
   clones with the URL Gitea reports. A `ROOT_URL` of `http://localhost:3000/`
   cannot work from a build container. Use a real DNS name, such as
   `https://gitea.example.internal/`.
2. **Allow webhooks to Drone.** Gitea 1.22 blocks webhooks to private addresses
   by default. In `app.ini`:
   ```ini
   [webhook]
   ALLOWED_HOST_LIST = drone.example.internal   ; or: private
   ```
   (container env: `GITEA__webhook__ALLOWED_HOST_LIST=drone.example.internal`)
3. **Create the OAuth2 application.** As the Gitea account that will be Drone
   admin, go to *Settings → Applications → Manage OAuth2 Applications*:
   * Application name: `drone`
   * Redirect URI: `https://drone.example.internal/login` (must match
     `DRONE_SERVER_PROTO://DRONE_SERVER_HOST/login` exactly)
   * Confidential client: **yes**

   Copy the client ID and secret.

   API alternative:
   ```bash
   curl -u ADMIN:PASSWORD -H 'Content-Type: application/json' \
     -d '{"name":"drone","redirect_uris":["https://drone.example.internal/login"],"confidential_client":true}' \
     https://gitea.example.internal/api/v1/user/applications/oauth2
   ```

> The local Gitea from `devsecops-gitea` listens on `127.0.0.1` with
> `ROOT_URL=http://localhost:3000/`, so it fails point 1 as it stands. Rebind it
> to a host name that containers can reach before connecting Drone to it.

### 4.4 Drone server

```bash
sudo scripts/20-install.sh server
```

The first run creates `/etc/drone/server.env` (mode 0600), generates
`DRONE_RPC_SECRET`, `DRONE_DATABASE_SECRET` and `DRONE_COOKIE_SECRET`, and stops.
Edit the file:

| Setting | Value |
|---|---|
| `DRONE_SERVER_HOST` / `DRONE_SERVER_PROTO` | Public name of Drone, e.g. `drone.example.internal` / `https` |
| `DRONE_GITEA_SERVER` | `https://gitea.example.internal` |
| `DRONE_GITEA_CLIENT_ID` / `_SECRET` | From §4.3 |
| `DRONE_USER_CREATE` | `username:<gitea-login>,admin:true` (first admin; admins can mark repos *Trusted*) |
| `DRONE_USER_FILTER` | Optional: the Gitea orgs/users allowed to log in |
| `DRONE_DATADOG_ENABLED` | **Must be `false`.** The image enables usage reporting to `stats.drone.ci` by default. The install script refuses to continue otherwise. |

Re-run `sudo scripts/20-install.sh server`. It installs
`/etc/containers/systemd/drone-server.container` (Quadlet), starts
`drone-server.service` and waits for `/healthz`. Data lives in `/var/lib/drone`.

**TLS:** either terminate TLS at your reverse proxy and forward to
`127.0.0.1:8080`, or put the certificate in `/etc/drone/tls/`, set
`DRONE_TLS_CERT`/`DRONE_TLS_KEY`, and switch the unit to `PublishPort=443:443`
with the `/etc/drone/tls` volume (both are commented in
`systemd/drone-server.container`).

```bash
firewall-cmd --permanent --add-port=8080/tcp && firewall-cmd --reload   # or 443
```

### 4.5 Runner(s)

On each runner host (it can be the server host):

```bash
sudo scripts/20-install.sh runner
```

Edit `/etc/drone/runner.env`: `DRONE_RPC_PROTO`, `DRONE_RPC_HOST` (the Drone
server name) and `DRONE_RPC_SECRET` (copy it from the server's env file). Then
re-run. The script:

* enables **`podman.socket`**. The Docker runner drives Podman through its
  Docker-compatible API (`/run/podman/podman.sock`, mounted as
  `/var/run/docker.sock`);
* installs `drone-runner.container` with `SecurityLabelDisable=true` so the
  container can use the socket under SELinux enforcing;
* sets `DRONE_RUNNER_CLONE_IMAGE=docker.io/drone/git:latest`, the preloaded
  image. The clone step uses pull policy *if-not-exists*, so it never contacts
  a registry;
* waits for `successfully pinged the remote server` in the runner log.

Anything with access to the engine socket is root-equivalent on that host.
Dedicate runner hosts to CI and do not run other workloads there.

### 4.6 Drone CLI (optional)

```bash
sudo scripts/20-install.sh cli
export DRONE_SERVER=https://drone.example.internal
export DRONE_TOKEN=<Drone UI → User settings → Token>
drone info
```

### 4.7 Internal CA

* **Drone server → Gitea:** mount your CA bundle over the Alpine path in the
  server unit, e.g. `Volume=/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem:/etc/ssl/certs/ca-certificates.crt:ro,Z`.
  Keep `DRONE_GITEA_SKIP_VERIFY=false`.
* **Runner → Drone:** same mount on the runner unit.
* **Clone and other steps:** `DRONE_RUNNER_VOLUMES` in `runner.env` mounts a file
  into *every* step container. `drone/git` is Alpine, so it reads
  `/etc/ssl/certs/ca-certificates.crt`. The scanner image needs no CA: it makes
  no network calls.

### 4.8 First login and first repository

1. Open `https://drone.example.internal`. You are redirected to Gitea. Sign in
   as the `DRONE_USER_CREATE` account and approve the grant.
2. Click *SYNC*, open a repository and click *ACTIVATE*. Drone creates the
   Gitea webhook itself.
3. Commit `drone/.drone.yml` to the repository root as `.drone.yml` and push.

---

## 5. The security pipeline

### 5.1 Steps

| Step | Command | Fails when |
|---|---|---|
| `scanner-data` | `drone-scan info` + `db-check` | Any DB is older than `MAX_DB_AGE_DAYS` (7) |
| `sbom-syft` | Syft → `sbom.syft.json`, `sbom.cdx.json` (CycloneDX), `sbom.spdx.json` | Syft errors |
| `grype` | Grype on the Syft SBOM → table, JSON, SARIF | Grype errors (DB missing or stale) |
| `trivy-fs` | Trivy `vuln,secret,misconfig` on the checkout → table, JSON, SARIF | Trivy errors |
| `trivy-image` | If `dist/image.tar` exists: Syft + Grype + Trivy on the image archive | Scanner errors |
| `security-gate` | `drone-scan gate` combines every report and applies the policy | **Blocking findings** (always runs) |
| `evidence` | `drone-scan archive`: all reports + `SHA256SUMS` in a `.tar.gz` | Never (always runs) |

Scan steps fail only when the *tool* fails. Findings are judged once, in
`security-gate`, so a single build log shows the full picture from both
scanners. Grype (on the SBOM) and Trivy (directly) are independent engines with
different advisory sources. On the test fixture both reported the same 8
critical and 16 high vulnerabilities.

### 5.2 Gate policy (step `environment`)

| Variable | Default | Meaning |
|---|---|---|
| `SCAN_FAIL_ON` | `high` | Vulnerability threshold: `critical`, `high`, `medium`, `low`, `none` |
| `SCAN_MISCONFIG_FAIL_ON` | `high` | Threshold for Dockerfile, Kubernetes, Terraform and other IaC findings |
| `SCAN_SECRETS_FAIL` | `true` | Any detected secret blocks |
| `SCAN_ONLY_FIXED` | `false` | `true` = vulnerabilities with no available fix do not block |

For **image** scans, consider `SCAN_ONLY_FIXED: "true"`. RHEL/UBI images
routinely carry HIGH CVEs that Red Hat has not fixed yet. The bundled UBI 9.8
base shows 17 of them, all with "no fix", and without this setting they would
block every image build.

### 5.3 Exceptions (allow-lists)

Scanners run from an empty directory with explicit config paths. Files in the
repository (`.grype.yaml`, `.syft.yaml`, `trivy.yaml`, `.trivyignore`) are
**ignored**, so a developer cannot silence findings from their branch.
Exceptions are admin-managed:

1. Edit `config/scanners/grype.yaml` (`ignore:`) and/or
   `config/scanners/trivyignore`. Record an owner, a reason and a review date
   for each entry.
2. Rebuild the scanner image (§7). Exceptions ship with the data.

The gate still runs inside the repository's own `.drone.yml`, and a developer
can delete the step. To make the gate mandatory, protect the pipeline file:
enable Drone's **Protected** repository setting (unsigned pipeline changes then
need approval) and use Gitea branch protection so changes to `.drone.yml` need
review.

### 5.4 Scanning your built image

Have your build step write a Docker or OCI archive to `dist/image.tar`
(`podman save -o dist/image.tar …`, `buildah push … oci-archive:dist/image.tar`).
The `trivy-image` step picks it up. Set `IMAGE_ARCHIVE` to change the path.

### 5.5 Reports

Reports are written to a per-build temp volume (`/reports`). `evidence` creates
`security-reports-<repo>-<build>.tar.gz` in the workspace. Drone does not keep
workspace files after the build, so uncomment the upload in the `evidence` step
to send the archive to an internal artefact store (e.g. the Artifactory in
`/work/acb/jfrog`). The archive contains `findings.tsv`, `blocking.tsv`, all
JSON/SARIF reports, and the CycloneDX and SPDX SBOMs.

### 5.6 Scheduled rescans

New CVEs appear against unchanged code. Add a Drone cron job on the default
branch so the gate runs against fresh data after each image refresh:

```bash
drone cron add --branch main org/repo nightly @daily
```

---

## 6. Optional: internal registry

With several runners, push the images once:

```bash
skopeo login registry.internal.example
scripts/15-push-registry.sh registry.internal.example
```

The script copies straight from the archives and prints the pushed digests
(recorded in `metadata/registry-push-*.tsv`). Then:

* `runner.env`: `DRONE_RUNNER_CLONE_IMAGE=registry.internal.example/drone/git@sha256:<digest>`
* Quadlet units: `Image=registry.internal.example/drone/drone:2.24.0` (and the runner)
* `.drone.yml`: `image: registry.internal.example/devsecops/drone-scanners:stable`
  with **`pull: always`**, so runners pick up `stable` each time it moves
* Registry credentials for private repositories: `DRONE_DOCKER_CONFIG` on the
  runner, or Drone's `image_pull_secrets`.

---

## 7. Refreshing vulnerability data

Scanning with old data gives false confidence. `db-check` fails builds once any
DB is older than **7 days** (`MAX_DB_AGE_DAYS`, matching Grype's
`max-allowed-built-age: 168h`). Plan a **weekly** refresh:

| Where | Step |
|---|---|
| Connected builder | `scripts/00-download.sh` (refreshes data; artefacts already downloaded are reused) |
| Connected builder | `scripts/05-build-scanner-image.sh` → new tag `drone-scanners:<new date>` |
| Connected builder | `scripts/99-package.sh` (the scanner image archive is the only large change) |
| Air gap | `scripts/10-load.sh` on runners (re-tags `stable`), **or** `scripts/15-push-registry.sh` |

Pipelines keep using `:stable` and pick up new data with no YAML change. The
data date is printed in every build log (`scanner-data`). **Rollback:** re-tag
the previous date as `stable`
(`podman tag localhost/devsecops/drone-scanners:<old> localhost/devsecops/drone-scanners:stable`).
Note that `db-check` will still reject it once it is older than 7 days.

To upgrade a scanner, Drone or the runner, change `config/versions.env` (version,
digest, SHA-256) and run the same sequence plus `30-verify-offline.sh`.

---

## 8. Proving it works offline: `30-verify-offline.sh`

```bash
sudo scripts/30-verify-offline.sh          # KEEP=1 leaves the stack running
```

Needs a Gitea image (`GITEA_IMAGE`, default `docker.io/gitea/gitea:1.22` from
the `devsecops-gitea` bundle). The script builds a throwaway stack on a Podman
**`--internal` network (no gateway)** and follows the real user path:

1. Gitea test instance, admin user, OAuth2 app;
2. Drone server started from `config/drone-server.env.example`, runner from
   `config/drone-runner.env.example` on the Podman socket;
3. checks that the Drone container **cannot** reach `https://github.com`;
4. browser-equivalent OAuth login (Gitea login → Drone `/login` → grant → session);
5. creates `demo-vulnerable` (**private**, which exercises clone authentication)
   and `demo-clean`, activates both in Drone (Drone installs the webhooks) and
   pushes them;
6. waits for the **webhook-triggered** builds of `drone/.drone.yml`. Every step
   container gets a black-hole `HTTP(S)_PROXY`, so any attempt to reach the
   Internet would fail loudly;
7. checks the results, then saves step logs, build JSON and `summary.json` to
   `evidence/verify-<timestamp>/`.

Result on this builder (2026-09-11, podman 5.8.2): **all assertions passed**.

| Build | Expected | Observed |
|---|---|---|
| `demo-vulnerable` | failure at `security-gate`; all scan steps succeed | failure; `clone`, `scanner-data`, `sbom-syft`, `grype`, `trivy-fs`, `trivy-image`, `evidence` succeeded; gate exit 1 |
| `demo-vulnerable` gate content | Grype + Trivy vulns, secret, misconfig | 8 CRITICAL / 16 HIGH from each engine; `github-pat` secret; `DS-0002` root user; UBI image CVEs |
| `demo-clean` | success, gate PASS | success, `SECURITY GATE: PASS` |

**Scope of the proof.** The Drone, Gitea and runner containers ran on a network
with no route out. The runner's per-build networks are created through the
Docker API, which cannot mark them internal. For build steps, "offline"
therefore rests on three things: the scanners' explicit offline flags, the
black-hole proxy (never contacted), and the `--network=none` scanner tests in
`05`/`10`. Inside a real air gap there is no route anyway.

Drone does not officially list Podman's Docker-compatible API as a supported
engine. It was verified here with podman 5.8.2, while RHEL 9.6 ships podman 5.4.
**Run `30-verify-offline.sh` once on a RHEL 9.6 runner host** before relying on
it. If it fails there, install Docker CE from offline RPMs on runner hosts and
use `10-load.sh docker`.

---

## 9. Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Login loops or `redirect_uri` error | The Gitea OAuth redirect URI must equal `DRONE_SERVER_PROTO://DRONE_SERVER_HOST/login` |
| Repository activated but pushes start no build | Gitea blocks the webhook: set `[webhook] ALLOWED_HOST_LIST`; check *Repo → Settings → Webhooks → Recent deliveries* |
| `clone` step: `Could not resolve host` / connection refused | `ROOT_URL` of Gitea is not reachable from containers (e.g. `localhost`) |
| `clone` step fails trying to pull `drone/git` | Clone image not loaded on this runner, or `DRONE_RUNNER_CLONE_IMAGE` does not match the loaded tag |
| Step: `image not known` / pull timeout | Scanner image not loaded on this runner (`10-load.sh`), or use the registry with `pull: always` |
| `scanner-data`: `FAIL grype-db: N days old` | Data is older than 7 days: refresh the image (§7) |
| `grype`: `the vulnerability database was built N days ago (max allowed age is 168h)` | Same as above |
| Runner log: `cannot ping the docker daemon` | `systemctl enable --now podman.socket`; SELinux: keep `SecurityLabelDisable=true` |
| Runner log repeats `cannot ping the remote server` | Wrong `DRONE_RPC_HOST`/`DRONE_RPC_PROTO`, a firewall, or `DRONE_RPC_SECRET` differs from the server's |
| Server keeps trying to reach `stats.drone.ci` | `DRONE_DATADOG_ENABLED=false` missing from `server.env` |
| Gate blocks base-image CVEs that have no fix | `SCAN_ONLY_FIXED: "true"` on the gate step, or an admin exception (§5.3) |
| Trivy warns `Unable to find python site-packages` | Harmless: license detection needs installed packages. Vulnerability matching uses `requirements.txt` |

---

## 10. Security notes and known limitations

* **Runner = root on its host** (engine socket). Dedicate runner hosts to CI.
  Don't enable `DRONE_RUNNER_PRIVILEGED_IMAGES`. Grant *Trusted* only to repos
  that need host mounts.
* **Secrets:** keep `/etc/drone/*.env` at mode 0600. `DRONE_DATABASE_SECRET`
  encrypts repository secrets in the database. Back it up together with
  `/var/lib/drone`: without it, stored secrets cannot be decrypted.
* **`drone/git` is from 2023.** Upstream publishes no newer build. It only runs
  `git clone` against your Gitea. Scan it yourself
  (`drone-scan trivy-image images/drone-git-amd64.tar /tmp/r`) and decide
  whether to accept it or rebuild a UBI-based clone image.
* **SQLite** means a single server. For HA or large build volumes, switch to
  PostgreSQL (`DRONE_DATABASE_DRIVER=postgres`).
* **Scanner coverage:** SCA (dependencies, OS packages), secrets and IaC
  misconfiguration. It does not replace SonarQube (SAST), ZAP (DAST) or
  Gitleaks' full-history secret scanning in the other bundles.
* **Backups:** `/var/lib/drone` (database), `/etc/drone` (config + secrets).
