Deployment guide

Clair 4.9.0

Overview

This guide shows you how to launch and use the Clair 4.9.0 on Ubuntu 24.04 AMI from SecureAnyCloud on Amazon EC2. It is one version of the SecureAnyCloud OWASP ZAP and vulnerability scanners listing on AWS Marketplace. You pick it as the software version when you subscribe and launch.

Clair is the open source container image vulnerability scanner from the Quay project. You give it an image manifest. Its indexer downloads the image layers and works out which operating system packages and language dependencies are inside. Its matcher compares that inventory against vulnerability data, which its updaters keep current from the distributions and advisory databases (Ubuntu, Debian, Alpine, Red Hat, SUSE, Oracle, Photon, Amazon Linux, the OSV database for language ecosystems, and more).

The result is a vulnerability report per image. Registries, CI pipelines and admission controllers call Clair over an HTTP API. The clairctl client on the image wraps that API for you.

Upstream Clair listens on every interface with no authentication and no TLS, and it downloads image layers from whatever URLs a client names. The SecureAnyCloud image turns it into a service you can put in a VPC.

What you get:

  • TLS and a password from the first boot. nginx serves the API on port 443 (https://<host>/) with a certificate generated on your instance's first boot for its DNS name and IP addresses. Every API request needs HTTP basic authentication with the user clair and a password generated on that first boot. The password is written to /root/clair-credentials.txt, readable by root only. Only the /healthz health check is open. Clair itself listens on 127.0.0.1 only, and nothing listens on port 80.
  • An egress firewall for the scanner. A client chooses the layer URLs Clair fetches, so an unprotected Clair can be pointed at addresses inside your network. On this image, the kernel refuses every connection that the clair user opens to these destinations: the metadata service, link-local, carrier-grade NAT, private (RFC 1918), benchmarking, multicast and reserved addresses, and your own VPC's address ranges (read from EC2 metadata on every boot). The service's own cgroup adds a second, independent block. IPv6 is closed by default. The rules load before Clair on every boot, and Clair refuses to start without them.
  • Its own database, on its own disk. PostgreSQL 16 listens on 127.0.0.1 only, with a database password generated on first boot. The cluster lives on a separate 40 GiB gp3 data volume mounted at /var/lib/postgresql, which you can resize on its own.
  • Fresh vulnerability data. No vulnerability data is baked into the image. Clair's updaters download the full database when the instance first starts and refresh it every 6 hours, so the data is never older than your launch.
  • Locked down. Clair runs as an unprivileged user in a sandboxed service. Clair, nginx and the first-boot service cannot reach the instance metadata service, and the AMI requires IMDSv2. Requests for host names the instance does not serve are dropped.
  • Built from source. Clair 4.9.0 and clairctl are compiled from the upstream release (pinned by SHA-256) with the pinned Go 1.27.3 toolchain. Dependencies are raised to versions that fix the published advisories in the upstream set. The image runs on fully patched Ubuntu 24.04 LTS with unattended security upgrades switched on.

What is on the image: Clair 4.9.0 and clairctl (/opt/clair, licence and build provenance in /usr/share/doc/clair), PostgreSQL 16, nginx, Ubuntu 24.04 LTS, cloud-init and the AWS Systems Manager agent.

Default user: ubuntu. Architecture: x86_64. Recommended instance type: m5.xlarge (4 vCPU, 16 GiB) for the fastest first database download. The listing's default m5.large (2 vCPU, 8 GiB) also works, with a slower first run.

Prerequisites

  • An AWS account subscribed to the listing, an EC2 key pair, and a VPC subnet that your registries and CI runners can reach. The instance needs outbound HTTPS to the internet, both to the vulnerability feeds (Ubuntu, Debian, Alpine, Red Hat, SUSE, OSV and the others) and to the registries that hold the images you scan.
  • A security group that allows TCP 22 (SSH) from your own networks only, and TCP 443 from the machines that call the API (CI runners, your registry, developers). Nothing else needs to be open.
  • An instance with at least 8 GiB of memory. The first download of the vulnerability database is the heaviest workload Clair runs. m5.xlarge (16 GiB) seeds it faster and leaves room for scanning large images. m5.large (8 GiB) works too. Clair's memory is held to 60% of RAM (see Step 10).
1

Launch the instance

In the AWS Marketplace listing, choose Continue to Subscribe, then Continue to Configuration. Pick Clair 4.9.0 on Ubuntu 24.04 as the software version, select your Region, and choose Launch through EC2. Select m5.xlarge, your key pair, and a security group as described above.

The AMI attaches the 40 GiB data volume for /var/lib/postgresql automatically. If you will keep index reports for many thousands of images, increase its size in the storage section of the launch wizard.

To launch with the AWS CLI instead, replace the values in angle brackets:

aws ec2 run-instances --region <region> --image-id <ami-id-from-the-listing> \
  --instance-type m5.xlarge --key-name <your-key-pair> \
  --security-group-ids <your-security-group> --subnet-id <your-subnet> \
  --metadata-options HttpTokens=required,HttpEndpoint=enabled \
  --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=clair}]'
2

Connect to your instance

Version SSH user

Clair 4.9.0 on Ubuntu 24.04 ubuntu

ssh -i <your-key.pem> ubuntu@<public-ip>
3

Check the services

First boot generates the certificate, the database password and the API password. The egress firewall, PostgreSQL, Clair and nginx then start. Check that they are running:

systemctl is-active clair nginx postgresql@16-main sac-clair-egress sac-clair-vpc-cidrs
sudo test -f /var/lib/sac/data/clair-firstboot.done && echo FIRSTBOOT_DONE
active active active active active FIRSTBOOT_DONE

Only SSH and nginx listen on the network. Clair's API (6060), its introspection port (8089) and PostgreSQL (5432) are on loopback only:

sudo ss -lntp | awk 'NR>1 {print $4, $6}' | sed -E 's/pid=[0-9]+,fd=[0-9]+/.../g' | sort
0.0.0.0:22 users:(("sshd",...),("systemd",...)) 0.0.0.0:443 users:(("nginx",...),("nginx",...),("nginx",...),("nginx",...),("nginx",...)) 127.0.0.1:5432 users:(("postgres",...)) 127.0.0.1:6060 users:(("clair",...)) 127.0.0.1:8089 users:(("clair",...)) 127.0.0.53%lo:53 users:(("systemd-resolve",...)) 127.0.0.54:53 users:(("systemd-resolve",...)) [::]:22 users:(("sshd",...),("systemd",...)) [::]:443 users:(("nginx",...),("nginx",...),("nginx",...),("nginx",...),("nginx",...))

The database is on its own volume:

df -h /var/lib/postgresql | awk 'NR==2 {print $2, $6}'
40G /var/lib/postgresql 15G /
4

Wait for the vulnerability database

The image ships no vulnerability data, so nothing in it is stale on the day you launch. When Clair first starts, its updaters download the full database from every source. These are the Ubuntu, Debian, Alpine, SUSE, Oracle Linux, Photon and Amazon Linux security feeds, Red Hat's VEX documents, the OSV database for language packages (Go, npm, PyPI, Maven, RubyGems, crates.io, NuGet, Packagist and others), and the CVSS enrichment data.

On our test launch, the first run took 114 minutes on a 4 vCPU, 16 GiB instance. It stored about 10.1 million vulnerability records (a 15 GB database). Allow 1 to 3 hours. The first run is the heaviest work Clair does. It needs about 7 GiB of memory and, while it runs, several GiB of temporary download space, which is why we recommend 16 GiB of memory. On a 2 vCPU, 8 GiB instance (m5.large), the first run also completes, in about 2.5 hours on our test.

Red Hat's feed is usually the last to finish. Its updater fetches every advisory changed since Red Hat's weekly archive one by one, so it takes longest in a week when Red Hat has republished many advisories. After the first run, Clair refreshes every source every 6 hours and only downloads what changed.

Clair answers API calls while it seeds, but a report is only complete once the first run has finished. To see where it is, run:

sudo clair-scan --status
updater | ok | last_success ----------------------------------------------+----+------------------------ alpine-community-edge-updater | t | 2026-11-08 09:41:54+00 alpine-community-v3.10-updater | t | 2026-11-08 09:41:52+00 alpine-community-v3.11-updater | t | 2026-11-08 09:41:52+00 alpine-community-v3.12-updater | t | 2026-11-08 09:41:54+00 alpine-community-v3.13-updater | t | 2026-11-08 09:41:54+00 alpine-community-v3.14-updater | t | 2026-11-08 09:41:52+00 ... oracle-2007-updater | f | oracle-2008-updater | f | ... ubuntu/updater/xenial | t | 2026-11-08 09:41:54+00 (125 rows)

ok is t for every source that has completed.

Oracle Linux: on our test launch, linux.oracle.com answered every request from Clair's OVAL fetcher with HTTP 403. It refuses that fetcher's user agent, although the same files download normally with other clients. The twenty oracle-YYYY-updater rows stayed at f, and Clair reports no findings for Oracle Linux images while that lasts. Every other source completed (t). Clair retries every 6 hours, so the rows turn to t without any action from you once Oracle accepts the requests again. Until then, scan Oracle Linux images with another tool. To check, run:

sudo clair-scan --status | awk '/oracle-/ {n++; if ($3 == "t") ok++} END {print ok+0 " of " n+0 " Oracle Linux sources ok"}'
sudo journalctl -u clair -o cat -g 'oracle-[0-9]+-updater: ovalutil' | tail -1 | grep -oE 'ovalutil: fetcher got unexpected HTTP response: [0-9]+ \([^)]*\)' | sort | uniq -c
0 of 20 Oracle Linux sources ok 20 ovalutil: fetcher got unexpected HTTP response: 403 (403 Forbidden)

The first run is complete when the journal says so. Clair adds one more line each time it restarts:

sudo journalctl -u clair -o cat -g 'starting background updates'
2026/11/08 09:30:44 INFO starting background updates interval=6h0m0s
5

Get your API URL and password

The credentials file is readable by root only. This command prints it with the password hidden. To show the password itself, run sudo cat /root/clair-credentials.txt:

sudo sed -E 's/^(CLAIR_API_PASSWORD=).*/\1<hidden>/' /root/clair-credentials.txt
# Clair 4.9.0 - generated on first boot by clair-firstboot.service
# This password is unique to this instance; none was baked into the image. Keep this file secret.
# Every API request needs it (HTTP basic auth over HTTPS); only /healthz is open. CLAIR_API_URL=https://ec2-52-87-223-107.compute-1.amazonaws.com/ CLAIR_HOST=ec2-52-87-223-107.compute-1.amazonaws.com CLAIR_API_USER=clair CLAIR_API_PASSWORD=<hidden> CLAIR_CA_CERT=/etc/nginx/tls/clair.crt CLAIR_CERT_SHA256=8A:41:95:2E:4E:4A:62:EC:CC:A5:D8:76:9F:88:6C:4A:2D:C0:BF:56:35:C7:05:3F:25:6A:20:BE:3D:0A:70:C1
#
# The vulnerability database downloads on first boot (allow 1 to 3 hours; see the guide):
sudo clair-scan --status
# Scan an image on this instance:
sudo clair-scan docker.io/library/alpine:3.19
# From a client:
curl --cacert <a copy of clair.crt> -u clair:<CLAIR_API_PASSWORD> https://<CLAIR_HOST>/indexer/api/v1/index_state
# New API password:
sudo clair-rotate-password
# New database password:
sudo clair-rotate-db-password
# Change the name it serves as:
sudo clair-set-hostname <your-dns-name>
# User guide: https://secureanycloud.com/stacks/clair-4-9-0/
6

Scan an image on the instance

clair-scan runs the clairctl client against this instance's Clair. It asks Clair to index the image, then prints the vulnerability report. Clair pulls the layers straight from the registry, inside the egress firewall. Run it with root privileges, because it runs clairctl as the unprivileged clair user:

sudo clair-scan docker.io/library/alpine:3.19.0
alpine:3.19.0 found busybox 1.36.1-r15 CVE-2025-46394 (fixed: 1.36.1-r21) alpine:3.19.0 found busybox 1.36.1-r15 CVE-2024-58251 (fixed: 1.36.1-r21) alpine:3.19.0 found busybox 1.36.1-r15 CVE-2023-42363 (fixed: 1.36.1-r17) alpine:3.19.0 found busybox 1.36.1-r15 CVE-2023-42365 (fixed: 1.36.1-r19) alpine:3.19.0 found busybox 1.36.1-r15 CVE-2023-42364 (fixed: 1.36.1-r19) alpine:3.19.0 found busybox 1.36.1-r15 CVE-2023-42366 (fixed: 1.36.1-r16) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2025-46394 (fixed: 1.36.1-r21) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2024-58251 (fixed: 1.36.1-r21) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2023-42363 (fixed: 1.36.1-r17) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2023-42365 (fixed: 1.36.1-r19) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2023-42364 (fixed: 1.36.1-r19) alpine:3.19.0 found busybox-binsh 1.36.1-r15 CVE-2023-42366 (fixed: 1.36.1-r16)

--json prints Clair's full vulnerability report instead, which is the format a pipeline or policy engine needs. For example, count the findings per package:

sudo clair-scan --json docker.io/library/alpine:3.19.0 | jq -r '[.vulnerabilities[] | .package.name] | group_by(.) | map("\(.[0]) \(length)") | .[]'
busybox 6 musl 3 openssl 12

You can name several images in one call. clair-scan pulls anonymously and keeps no registry credentials on the instance, so it suits public images.

For images in private registries, run clairctl on a machine that is logged in to the registry (Step 7). There, clairctl reads your Docker credentials and hands Clair the layer URLs with the registry's short-lived access headers, so the registry password never reaches the Clair instance.

7

Use the API from your CI and registries

Copy the certificate to the machines that call Clair, or use your own certificate (Step 10). Keep the password in your CI system's secret store. Every path except /healthz needs HTTP basic authentication with the user clair. Request the indexer state as a quick check that your client, the certificate and the password all work:

# from a host inside your network that has a copy of clair.crt curl --cacert clair.crt -u clair:<CLAIR_API_PASSWORD> https://<CLAIR_HOST>/indexer/api/v1/index_state
{"state":"150b86c03ad8d5fd1b8fdff4a351aed8"}

To scan from a client, install the same clairctl release (Clair v4.9.0 release assets) and point it at the instance. clairctl sends the image manifest to POST /indexer/api/v1/index_report and then reads GET /matcher/api/v1/vulnerability_report/<manifest-digest>.

Tools that integrate Clair v4 (Quay, and CI plugins that speak the Clair v4 API) need the same three things: the URL https://<CLAIR_HOST>/, the user clair with the password, and the certificate. The OpenAPI description of every endpoint is served at https://<CLAIR_HOST>/openapi/v1.

8

Check the security posture

Everything but the health check needs the password. A request for a host name the instance does not serve is dropped without an answer, and nothing answers on port 80. Verify this with:

sudo cp /etc/nginx/tls/clair.crt /tmp/clair.crt; C=/tmp/clair.crt curl -s --cacert $C -o /dev/null -w 'index_state, no password -> HTTP %{http_code}\n' https://localhost/indexer/api/v1/index_state curl -s --cacert $C -K <(printf 'user = "clair:not-the-password"\n') -o /dev/null -w 'wrong password -> HTTP %{http_code}\n' https://localhost/indexer/api/v1/index_state curl -s --cacert $C -o /dev/null -w 'health check -> HTTP %{http_code}\n' https://localhost/healthz curl -s --cacert $C -H 'Host: evil.example' -o /dev/null -w 'unknown host -> %{http_code}\n' https://localhost/healthz || true curl -s -o /dev/null -w 'plain http :80 -> %{http_code}\n' --connect-timeout 3 http://127.0.0.1/ || true
index_state, no password -> HTTP 401 wrong password -> HTTP 401 health check -> HTTP 200 unknown host -> 000 plain http :80 -> 000

000 means no answer: nginx closes the connection for a host name it does not serve, and nothing listens on port 80.

The certificate is a leaf certificate for the instance's names:

openssl x509 -noout -ext subjectAltName,basicConstraints,keyUsage,extendedKeyUsage -in /etc/nginx/tls/clair.crt
X509v3 Subject Alternative Name: DNS:ec2-52-87-223-107.compute-1.amazonaws.com, IP Address:52.87.223.107, IP Address:172.31.82.5, IP Address:127.0.0.1, DNS:localhost X509v3 Basic Constraints: critical CA:FALSE X509v3 Key Usage: critical Digital Signature, Key Encipherment X509v3 Extended Key Usage: TLS Web Server Authentication

The egress firewall. Clair downloads image layers from the URLs in the manifests clients send it, so the image restricts where the clair user may connect. On every boot, sac-clair-vpc-cidrs.service reads this instance's own VPC IPv4 ranges (and any VPC or subnet IPv6 ranges) from the EC2 metadata service as root. Then sac-clair-egress.service loads the rules before Clair starts. To see the ranges it found, run:

sudo cat /run/sac-clair/vpc-cidrs
# written by clair-vpc-cidrs from EC2 metadata at 2026-11-08T09:31:48Z source imds 4 172.31.0.0/16

The IPv4 rules refuse your VPC's ranges first, then the metadata, link-local, carrier-grade NAT, private, 0.0.0.0/8, benchmarking (198.18.0.0/15), IETF protocol (192.0.0.0/24), multicast and reserved ranges. Everything else is allowed, including the public internet where the feeds and public registries are:

sudo iptables -S OUTPUT | grep SAC
sudo iptables -S SAC-CLAIR-EGRESS | grep -E '^-A' | grep -v -- '-p udp'
-A OUTPUT -o lo -p tcp -m multiport --dports 6060,8089 -j SAC-CLAIR-LOOPBACK -A OUTPUT -m owner --uid-owner 997 -j SAC-CLAIR-EGRESS -A SAC-CLAIR-EGRESS -d 127.0.0.0/8 -o lo -j RETURN -A SAC-CLAIR-EGRESS -d 172.31.0.0/16 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 172.31.0.0/16 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 169.254.0.0/16 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 169.254.0.0/16 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 100.64.0.0/10 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 100.64.0.0/10 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 10.0.0.0/8 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 10.0.0.0/8 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 172.16.0.0/12 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 172.16.0.0/12 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 192.168.0.0/16 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 192.168.0.0/16 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 0.0.0.0/8 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 0.0.0.0/8 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 168.63.129.16/32 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 168.63.129.16/32 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 198.18.0.0/15 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 198.18.0.0/15 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 192.0.0.0/24 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 192.0.0.0/24 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 224.0.0.0/4 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 224.0.0.0/4 -j REJECT --reject-with icmp-admin-prohibited -A SAC-CLAIR-EGRESS -d 240.0.0.0/4 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 240.0.0.0/4 -j REJECT --reject-with icmp-admin-prohibited

IPv6 is closed by default. Loopback is allowed. When the VPC lookup succeeded, global unicast addresses (2000::/3) outside your VPC's IPv6 ranges and the documentation, Teredo and 6to4 ranges are also allowed. Everything else, including unique local, link-local and NAT64 addresses, is refused:

sudo ip6tables -S SAC-CLAIR-EGRESS | grep -E '^-A' | grep -v -- '-p udp'
-A SAC-CLAIR-EGRESS -d ::1/128 -o lo -j RETURN -A SAC-CLAIR-EGRESS -d fc00::/7 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d fc00::/7 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d fe80::/10 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d fe80::/10 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 64:ff9b::/96 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 64:ff9b::/96 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 64:ff9b:1::/48 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 64:ff9b:1::/48 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d ::ffff:0.0.0.0/96 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d ::ffff:0.0.0.0/96 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 2001::/32 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 2001::/32 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 2001:db8::/32 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 2001:db8::/32 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 2002::/16 -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -d 2002::/16 -j REJECT --reject-with icmp6-adm-prohibited -A SAC-CLAIR-EGRESS -d 2000::/3 -j RETURN -A SAC-CLAIR-EGRESS -p tcp -j REJECT --reject-with tcp-reset -A SAC-CLAIR-EGRESS -j REJECT --reject-with icmp6-adm-prohibited

If the metadata lookup ever fails or returns an incomplete answer (for example, an interface without its VPC range), the helper records source none. The IPv6 global rule is left out, so all IPv6 except loopback is refused. The IPv4 static ranges stay in force, one line in the journal says the VPC ranges are unknown, and Clair still starts and scans over IPv4.

Refused connections are reset at once (REJECT), so a manifest that points at an internal address fails immediately instead of hanging. Try it as the clair user. The metadata service, a private address, an address in your VPC and a benchmarking address are refused. A feed and the public registry are reachable:

V=$(awk '$1=="4" {print $2; exit}' /run/sac-clair/vpc-cidrs | cut -d/ -f1 | awk -F. '{print $1"."$2"."$3"."($4+10)}') for t in http://169.254.169.254/latest/meta-data/ http://10.0.0.1/ "http://${V}/" http://198.18.0.1/ https://secdb.alpinelinux.org/ https://registry-1.docker.io/v2/; do if code=$(sudo setpriv --reuid=clair --regid=clair --clear-groups -- curl -s -m 5 -o /dev/null -w '%{http_code}' "$t"); then echo "$t -> HTTP $code"; else echo "$t -> refused"; fi done
http://169.254.169.254/latest/meta-data/ -> refused http://10.0.0.1/ -> refused http://172.31.0.10/ -> refused http://198.18.0.1/ -> refused https://secdb.alpinelinux.org/ -> HTTP 200 https://registry-1.docker.io/v2/ -> HTTP 401

A 401 from the registry is its normal answer to an anonymous request, and shows that the registry is reachable.

Clair runs as the unprivileged clair user. Its service also carries the same ranges, plus your VPC's, as a cgroup IPAddressDeny list. This is a second block that does not depend on iptables. nginx and the first-boot service cannot reach the metadata service either:

ps -o user:8=,args= -C clair systemctl show clair nginx clair-firstboot -p Id -p IPAddressDeny
clair /opt/clair/clair -conf /etc/clair/config.yaml -mode combo IPAddressDeny=10.0.0.0/8 168.63.129.16/32 240.0.0.0/4 64:ff9b::/96 198.18.0.0/15 ::ffff:0.0.0.0/96 2001::/32 192.168.0.0/16 fe80::/10 64:ff9b:1::/48 2002::/16 169.254.0.0/16 0.0.0.0/8 100.64.0.0/10 224.0.0.0/4 fc00::/7 2001:db8::/32 192.0.0.0/24 172.16.0.0/12 Id=clair.service IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32 Id=nginx.service IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 168.63.129.16/32 Id=clair-firstboot.service

The rules are applied in one atomic iptables-restore transaction per address family. A reload replaces the chain in a single step, a reload that fails leaves the previous rules in place, and stopping the unit does not remove them. To re-apply the rules, run sudo systemctl reload sac-clair-egress.

9

Scan images in private registries on your network

Because private and VPC addresses are refused, Clair cannot fetch layers from a registry inside your network until you allow its range. List each registry's network, one CIDR per line, in /etc/sac/clair-egress-allow. The file must be root-owned and not group- or world-writable. Then reload the firewall and restart Clair. For example, for a registry at registry.<your-domain> in 10.20.30.0/24, <registry-cidr> is 10.20.30.0/24:

echo '<registry-cidr> # registry.<your-domain>' | sudo tee /etc/sac/clair-egress-allow
sudo systemctl reload sac-clair-egress && sudo systemctl restart clair

Allowed ranges are checked before every refusal, including your VPC's ranges, so a registry inside your own VPC works too. Only private ranges (RFC 1918, carrier-grade NAT and IPv6 unique local) can be allowed. An entry that overlaps the metadata service, link-local or 0.0.0.0/8 is rejected and the previous rules stay in force. Keep the list as narrow as your registries, because every allowed range is reachable by whoever can call the API.

10

Rotate the passwords and use your own DNS name

sudo clair-rotate-password replaces the API password with a new random one, writes it to /root/clair-credentials.txt and reloads nginx. The old password stops working at once, so update your CI secret store afterwards.

sudo clair-rotate-db-password does the same for the PostgreSQL password that Clair uses, and restarts Clair. PostgreSQL only ever receives its SCRAM-SHA-256 verifier. Rotate a password whenever it may have been exposed.

To use your own DNS name, point a DNS record at the instance, then set the name that the API is served as. This mints a new self-signed leaf certificate for the name (and the instance's IP addresses), updates nginx and the credentials file, and keeps the name across reboots. Give your clients the new certificate afterwards:

sudo clair-set-hostname <your-domain>

To use a certificate from your own certificate authority instead, copy it over the generated pair and reload nginx. Running clair-set-hostname again replaces it with a self-signed certificate:

sudo install -m 0644 <your-domain>.crt /etc/nginx/tls/clair.crt
sudo install -m 0600 <your-domain>.key /etc/nginx/tls/clair.key
sudo nginx -t && sudo systemctl reload nginx

Memory. Clair's Go heap is held to 60% of the instance's memory. clair.service works out the limit from /proc/meminfo every time it starts, so resizing the instance needs no edit. It also clears Clair's download spool in /var/cache/clair on every start.

sudo cat /run/clair-memlimit.env
GOMEMLIMIT=9697411KiB MemoryPeak=4043624448
11

Server components

Component Path

Clair and clairctl /opt/clair/clair, /opt/clair/clairctl (read only, owner root)

Licence and build provenance /usr/share/doc/clair/

Configuration /etc/clair/config.yaml (no secrets; the database password is in /etc/clair/pgpass, readable by Clair only)

Database PostgreSQL 16 cluster 16/main on the data volume at /var/lib/postgresql

Service units clair.service, clair-firstboot.service, sac-clair-egress.service, sac-clair-vpc-cidrs.service in /etc/systemd/system/

Egress rules /usr/local/sbin/clair-egress-apply (--print shows the ruleset), /usr/local/sbin/clair-vpc-cidrs (--print shows what the metadata lookup finds)

nginx site and password file /etc/nginx/sites-available/sac-clair, /etc/nginx/clair.htpasswd

Certificate and key /etc/nginx/tls/

Helpers /usr/local/sbin/clair-scan, clair-rotate-password, clair-rotate-db-password, clair-set-hostname, clair-guard

Credentials /root/clair-credentials.txt (mode 0600)

/opt/clair/clairctl --version grep -E '^(source|toolchain|changes)' /usr/share/doc/clair/BUILD-PROVENANCE.txt nginx -v 2>&1; psql --version
clairctl version v4.9.0 (claircore v1.5.57) source: https://github.com/quay/clair/releases/download/v4.9.0/clair-v4.9.0.tar.gz toolchain: go1.27.3 linux/amd64 (https://go.dev/dl/go1.27.3.linux-amd64.tar.gz, sha256 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40); build time only, not shipped changes: dependency raise only (go.mod, no source patch; vendor/ dropped): github.com/jackc/pgx/v5@v5.10.0 google.golang.org/grpc@v1.83.2 golang.org/x/net@v0.62.0 github.com/klauspost/compress@v1.20.2 github.com/go-jose/go-jose/v3@v3.0.5 go.opentelemetry.io/ nginx version: nginx/1.24.0 (Ubuntu) psql (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)

The settings that matter most:

sudo grep -nE '^(http_listen_addr|introspection_addr| period| connstring)' /etc/clair/config.yaml
8:http_listen_addr: "127.0.0.1:6060" 9:introspection_addr: "127.0.0.1:8089" 12: connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=10" 17: connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=20" 20: period: 6h 22: connstring: "host=127.0.0.1 port=5432 user=clair dbname=clair sslmode=disable passfile=/etc/clair/pgpass pool_max_conns=5"

Clair runs in combo mode (indexer, matcher and notifier in one process). The connection pools are sized in the connection strings (pool_max_conns). The updaters need two connections each while they store a feed.

To change a setting, edit /etc/clair/config.yaml using the names from the Clair configuration reference, then run sudo systemctl restart clair. The notifier has no delivery method configured. Add a webhook, AMQP or STOMP block there if you want notifications when new vulnerabilities affect images you have indexed.

Logs: use sudo journalctl -u clair for Clair, and /var/log/nginx/access.log for API requests (with the real client address).

12

Security recommendations

  • Allow port 22 in the security group from your own networks only, and port 443 only from the networks of your CI runners, registries and developers.
  • Keep the API password in your CI system's secret store. Rotate it (Step 10) when someone leaves or it may have been exposed.
  • Keep the egress allow-list (Step 9) as narrow as your private registries.
  • Use your own DNS name and certificate (Step 10).
  • Back up /var/lib/postgresql if you rely on the stored index reports. An EBS snapshot of the data volume is the simplest way. The vulnerability data itself is re-downloaded by the updaters.
  • Keep the operating system patched. Unattended security upgrades are on, and sudo apt-get update && sudo apt-get upgrade applies everything else.

Support and licensing

Clair is open source under the Apache License 2.0, with no per-user or per-CPU licence fee. The licence and notice are in /usr/share/doc/clair/. SecureAnyCloud is not affiliated with or endorsed by the Quay or Clair projects or Red Hat. The AMI price covers SecureAnyCloud's hardening, first-boot setup and support.

  • Email: sales@secureanycloud.com
  • Website: https://secureanycloud.com
  • Support: 24/7, with a 24 hour response SLA

Deploy on AWS

Launch Clair 4.9.0 on Ubuntu 24.04 on AWS with 24/7 support from SecureAnyCloud.

View on Marketplace

Need Help?

Our support team is available 24/7.

sales@secureanycloud.com

Deploy on AWS