Check CONTRIBUTING.md for contribution guidelines.
- OCI image secret scanner that works against any public OCI-compliant registry (Docker Hub, GHCR, Quay, GCR, MCR, Amazon ECR Public, self-hosted). It analyzes image layers, config metadata, and image history, then stores deduplicated findings by manifest digest.
- Traditional secret scanners often treat a container image as a flat blob or depend on a local Docker daemon. This project is designed around OCI image internals
- Docs Page
- Current Capabilities
- Install
- Postgres persistence
- How to start
- HTTP API
- Docker Compose deployment (Dockge / Komodo)
- License
- Support this project
The published site is built from web/ on main by .github/workflows/pages.yml. The docs source and the simulated browser demo both live under that directory.
- Public images from any OCI-compliant registry (Docker Hub, GHCR, Quay, GCR, MCR, Amazon ECR Public, self-hosted)
- Read-only scanning
- No secret verification
- No Docker daemon dependency required
- Manifest-aware and layer-aware scanning
- Scans final filesystem and deleted-layer artifacts
- Scans image config metadata, env vars, labels, and history
- Deduplicates findings by secret fingerprint and collapses repeated identical context snippets per manifest
- Native detectors for 60+ secret types plus TruffleHog defaults as a fallback layer
- Suppresses test/fixture/spec/e2e/acceptance path findings to reduce false positives in development images
Prerequisites:
- Go 1.25.7+
Install with Go:
go install github.com/brumbelow/layerleak@latest
layerleak --helpThe canonical install target is the module root. To pin a release explicitly:
go install github.com/brumbelow/layerleak@v1.0.0Replace v1.0.0 with the published v1.x.y tag you want.
Make sure your GOBIN or GOPATH/bin directory is on PATH.
Build from source:
git clone https://github.com/brumbelow/layerleak.git
cd layerleak
go build -o layerleak .
./layerleak --helpRun the API with a container image:
docker pull ghcr.io/brumbelow/layerleak:latest
docker run --rm \
-p 8080:8080 \
-e LAYERLEAK_DATABASE_URL='postgres://<user>:<password>@<host>:5432/layerleak?sslmode=disable' \
ghcr.io/brumbelow/layerleak:latestThe container image runs the API by default and sets LAYERLEAK_API_ADDR=0.0.0.0:8080.
Optional environment configuration:
cp .env.example .envResult and database configuration:
export LAYERLEAK_LOG_LEVEL=info
export LAYERLEAK_FINDINGS_DIR=findings
export LAYERLEAK_API_ADDR=127.0.0.1:8080
export LAYERLEAK_PERSIST_RAW_SECRETS=0
export LAYERLEAK_TAG_PAGE_SIZE=100
export LAYERLEAK_HTTP_TIMEOUT=30s
export LAYERLEAK_MAX_FILE_BYTES=1048576
export LAYERLEAK_MAX_LAYER_BYTES=536870912
export LAYERLEAK_MAX_LAYER_ENTRIES=50000
export LAYERLEAK_MAX_MANIFEST_BYTES=0
export LAYERLEAK_MAX_CONFIG_BYTES=0
export LAYERLEAK_MAX_TAG_RESPONSE_BYTES=8388608
export LAYERLEAK_MAX_REPOSITORY_TAGS=0
export LAYERLEAK_MAX_REPOSITORY_TARGETS=0
export LAYERLEAK_REGISTRY_REQUEST_ATTEMPTS=2
# Optional registry overrides; usually leave unset.
export LAYERLEAK_REGISTRY_BASE_URL=
export LAYERLEAK_REGISTRY_AUTH_URL=
export LAYERLEAK_DATABASE_URL=postgres://postgres:postgres@localhost:5432/layerleak?sslmode=disableThe same variables and their defaults live in .env.example, which is the source of truth for default values.
| Variable | Default | Purpose |
|---|---|---|
LAYERLEAK_LOG_LEVEL |
info |
Log level: debug, info, warn, or error. |
LAYERLEAK_FINDINGS_DIR |
unset | Where to write JSON findings files. If unset, defaults to findings/ under the nearest parent containing go.mod, falling back to the current working directory. |
LAYERLEAK_API_ADDR |
127.0.0.1:8080 |
Bind address for the API server. The container image overrides this to 0.0.0.0:8080. |
LAYERLEAK_PERSIST_RAW_SECRETS |
0 |
Set to 1 to write raw secret values and raw context snippets to disk and Postgres. Findings stay redacted by default. |
LAYERLEAK_HTTP_TIMEOUT |
30s |
Per-request timeout for every registry call (manifests, blobs, tag pages, auth tokens). Accepts any Go duration (30s, 2m, 1h). |
LAYERLEAK_MAX_FILE_BYTES |
1048576 (1 MiB) |
Max decompressed bytes buffered per file inside a layer. Files larger than this are skipped as oversize. Must be greater than zero. |
LAYERLEAK_MAX_LAYER_BYTES |
536870912 (512 MiB) |
Max decompressed layer stream bytes per layer. 0 disables the limit. |
LAYERLEAK_MAX_LAYER_ENTRIES |
50000 |
Max tar entries per layer. 0 disables the limit. |
LAYERLEAK_MAX_MANIFEST_BYTES |
0 |
Max manifest body bytes. 0 disables the limit. |
LAYERLEAK_MAX_CONFIG_BYTES |
0 |
Max image config body bytes. 0 disables the limit. |
LAYERLEAK_MAX_TAG_RESPONSE_BYTES |
8388608 (8 MiB) |
Max bytes per registry tag-list response page. 0 disables the limit. |
LAYERLEAK_TAG_PAGE_SIZE |
100 |
Registry tag-list page size for repository-wide scans. |
LAYERLEAK_MAX_REPOSITORY_TAGS |
0 |
Max tags enumerated per repository scan. 0 disables the limit. |
LAYERLEAK_MAX_REPOSITORY_TARGETS |
0 |
Max distinct targets resolved per repository scan. 0 disables the limit. |
LAYERLEAK_REGISTRY_REQUEST_ATTEMPTS |
2 |
Number of attempts (including the first) for each registry request. |
LAYERLEAK_REGISTRY_BASE_URL |
unset | Optional override. Normally layerleak derives this from each image reference; set only to force scans through a proxy or alternate endpoint. |
LAYERLEAK_REGISTRY_AUTH_URL |
unset | Optional override. Normally discovered from the registry's WWW-Authenticate challenge. |
LAYERLEAK_DATABASE_URL |
unset | If set, layerleak writes scans to Postgres and fails the command if persistence does not succeed. |
When any of the MAX_* limits is set to a positive value, exceeding it fails the scan with a clear error instead of silently truncating work.
Result behavior:
- Actionable findings remain in
findingsand drive the non-zero scan exit status. - Likely test/example/demo placeholders are emitted separately as suppressed example findings and do not count toward
total_findings. - Finding records include
disposition,disposition_reason, andline_numberto make triage and false-positive review easier. - If a configured operational limit is exceeded, layerleak still writes and renders the partial results produced before the failure, then exits with status
1because the scan is incomplete.
Layerleak ships versioned SQL migrations under migrations/.
Migrations are manual on purpose. The scanner does not auto-create or auto-upgrade the schema.
Layerleak requires PostgreSQL server >= 16.13 for DB-backed API and scanner persistence.
Apply the migrations with psql in order:
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0001_initial.up.sql
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0002_finding_occurrence_metadata.up.sql
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0003_scan_runs.up.sqlOr apply migrations using the container helper command:
docker run --rm \
-e LAYERLEAK_DATABASE_URL="$LAYERLEAK_DATABASE_URL" \
ghcr.io/brumbelow/layerleak:latest \
layerleak-migrate-uplayerleak-migrate-up is safe to rerun when migrations are already applied.
If it detects a partial migration state, it exits non-zero and asks for manual intervention.
The helper also enforces server version >= 16.13 and validates that the bundled postgresql-client-16
uses Ubuntu PGDG 24.04 packaging (.pgdg24.04+) at version >= 16.13-1.pgdg24.04+1.
Rollback the migrations in reverse order:
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0003_scan_runs.down.sql
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0002_finding_occurrence_metadata.down.sql
psql "$LAYERLEAK_DATABASE_URL" -f migrations/0001_initial.down.sqlOperational defaults:
- Migrations are expected to remain additive.
- The schema keeps current deduplicated state with
first_seen_atandlast_seen_at, and also stores append-only scan history inscan_runs. - Tag mappings are refreshed for tags touched by the current scan.
- Findings are deduplicated canonically by
(manifest_digest, fingerprint), and repeated identical context snippets are collapsed before persistence. - Scan history stores a redacted snapshot of the public result JSON, not raw values or raw snippets.
Secret-safety note:
- Postgres persistence stores redacted previews by default.
- If
LAYERLEAK_PERSIST_RAW_SECRETS=1, Postgres also stores raw finding values and raw snippets. - The
scan_runs.result_jsonsnapshot stays redacted. - Use a dedicated database or schema for layerleak.
- For the safest purge path, drop the dedicated database or schema instead of trying to surgically delete individual rows.
Show the CLI help:
layerleak --help
layerleak scan --helpRun a scan against a public OCI image on any supported registry:
./layerleak scan ubuntu
./layerleak scan library/nginx:latest --format json
./layerleak scan alpine:latest --platform linux/amd64
./layerleak scan mongo
./layerleak scan ghcr.io/homebrew/core/hello:latest
./layerleak scan quay.io/prometheus/busybox:latest
./layerleak scan gcr.io/distroless/static:nonroot
./layerleak scan public.ecr.aws/docker/library/alpine:3.20
./layerleak scan mcr.microsoft.com/hello-world:latestEvery scan writes a JSON findings file to the findings output directory.
If LAYERLEAK_FINDINGS_DIR is not set, the default output directory is findings/ under the nearest parent directory containing go.mod (typically the repo root), with a fallback to the current working directory when no repo root is found.
Those saved findings files contain finding records with redacted_value, redacted context_snippet, exact source location, disposition metadata, and line number for each finding.
If LAYERLEAK_PERSIST_RAW_SECRETS=1, the saved findings files also include raw value and raw_context_snippet.
If Postgres persistence is enabled, raw findings.value and finding_occurrences.raw_snippet stay empty unless LAYERLEAK_PERSIST_RAW_SECRETS=1.
For multi-arch images, layerleak skips attestation and provenance manifests such as application/vnd.in-toto+json instead of counting them as failed platform scans.
If you pass a bare repository name such as mongo, layerleak enumerates all public tags in that repository, resolves each tag to a digest, groups duplicate digests, and scans the distinct targets. If you want a single image only, pass an explicit tag or digest such as mongo:latest or mongo@sha256:....
Command syntax:
layerleak [command]
layerleak scan <image-ref> [flags]
Layerleak also ships a minimal JSON API under cmd/api.
The API is Postgres-backed and requires LAYERLEAK_DATABASE_URL; it does not serve from the findings files on disk.
Start it with:
go run ./cmd/apiOr run the API container:
docker run --rm \
-p 8080:8080 \
-e LAYERLEAK_DATABASE_URL='postgres://<user>:<password>@<host>:5432/layerleak?sslmode=disable' \
ghcr.io/brumbelow/layerleak:latestCurrent endpoints:
GET /healthPOST /api/v1/scansGET /api/v1/scans/{id}GET /api/v1/repositoriesGET /api/v1/repositories/{repository}/scansGET /api/v1/repositories/{repository}/findingsGET /api/v1/findings/{id}
GET /health returns {"status":"ok"} and does not require a configured store or scanner.
It is suitable for Kubernetes readiness probes and Docker Compose healthcheck targets.
POST /api/v1/scans stays synchronous and now returns scan_run_id whenever Postgres persistence is enabled.
API scan responses reuse the same redacted result schema as the CLI JSON output.
GET /api/v1/scans/{id} returns the persisted run metadata plus the stored redacted result snapshot.
Repository and finding endpoints also stay redacted: they return redacted_value and redacted context_snippet, never raw secret values or raw snippets from Postgres.
GET /api/v1/repositories/{repository}/scans and GET /api/v1/repositories/{repository}/findings accept an optional registry query parameter (for example ?registry=ghcr.io). When omitted, the registry defaults to docker.io for backward compatibility. Use this to fetch scans of repositories on GHCR, Quay, GCR, MCR, Amazon ECR Public, or any self-hosted registry.
The API does not include authentication. For org deployments, keep it on a private network and front it with your own authn/authz gateway or reverse proxy policy.
This repo ships a Compose stack in docker-compose.yml with db, migrate, and api services.
The db service baseline is pinned to postgres:16.13-alpine.
If you use a different Postgres image, keep the server version at 16.13 or newer.
Set deployment variables (export in shell or place in a .env file next to docker-compose.yml):
export LAYERLEAK_IMAGE=ghcr.io/brumbelow/layerleak:latest
export LAYERLEAK_DB_NAME=layerleak
export LAYERLEAK_DB_USER=layerleak
export LAYERLEAK_DB_PASSWORD=replace-me
export LAYERLEAK_API_PORT=8080Run migrations once before starting the API:
docker compose --profile manual run --rm migrateStart the API service:
docker compose up -d apiIn Dockge or Komodo, import the same Compose file and run the migrate service once before enabling the long-running api service.
Released under the MIT License — see LICENSE.
☕ Enjoying this project? Click here to support it
If this repo saved you time or helped you out, you can support future updates here:
Thank you :) it genuinely helps keep the project maintained.

