Skip to main content

Security Scanning: Find Vulnerabilities in Running Databases

Security vulnerabilities in your database are difficult to predict and track. They can be found in GRANTs, extensions, and even authentication methods. Thankfully, common vulnerabilities and exposures (CVEs) have been compiled into a standardized framework with severity scores, which Atlas uses in the Security Graph to automatically identify and rank CVEs in your databases.

With the atlas security scan command, you can access this information in the CLI on demand. Atlas connects to your database and reports the security issues found. The cve check is included by default, resolving the extensions installed in each database against the Security Graph, and reporting those affected by a published CVE, each with its severity level and the suggested fix.

The scan reads the live database, not a schema file or a migration directory, so it needs neither a dev database nor access to your codebase. Run scheduled scans against staging and production to catch CVEs published for versions that are already installed, and use the cve analyzer in CI to catch a vulnerable extension before it is installed.

atlas security scan and the security block are available to Atlas Pro users with the Security Graph enabled in their plan. Run the following command to use this feature:

atlas login

Scanning a Database

Point the scanner at a database with --url:

atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable"

Each line contains in the output contains the CVE identifier, its severity level, the title of the record (when applicable), and the version to upgrade to to solve the issue. Credentials are redacted from the report, so it is safe to keep in CI logs or forward to a webhook.

Scanning 1 database for security issues:

-- postgres://postgres:xxxxx@localhost:5432/app?sslmode=disable (postgres 13.23):
-- Extension "pg_trgm" version "1.5" is vulnerable to CVE-2026-14678 (ELEVATED):
PostgreSQL pg_trgm picksplit reads past end of buffer. Upgrade the database engine to
version 14.24 or later to resolve CVE-2026-14678: the fix for extension "pg_trgm" ships in
engine releases
-- Extension "pgcrypto" version "1.3" is vulnerable to CVE-2026-14663 (ELEVATED):
PostgreSQL pgcrypto, for OpenSSL-disabled ciphers, silently encrypts to and decrypts
from cleartext. Upgrade the database engine to version 14.24 or later to resolve
CVE-2026-14663: the fix for extension "pgcrypto" ships in engine releases
-- Extension "pgcrypto" version "1.3" is vulnerable to CVE-2026-2005 (HIGH): PostgreSQL
pgcrypto heap buffer overflow executes arbitrary code. Upgrade the database engine to
version 14.21 or later to resolve CVE-2026-2005: the fix for extension "pgcrypto" ships in
engine releases

-------------------------
-- 864.441209ms
-- 3 issues found: 1 high, 2 elevated

Findings are matched against the installed extension version and the engine version of the scanned database, because the fix for a built-in extension such as hstore or pgcrypto ships in an engine release rather than in the extension itself. That is why the three findings above name an engine upgrade rather than an extension upgrade. Extensions are read at the database level, so a URL bound to a single schema still reports every extension installed in that database.

A database with nothing to report says so:

Scanning 1 database for security issues:

-- postgres://postgres:xxxxx@localhost:5432/app?sslmode=disable (postgres 13.23):
-- no issues found in 3 extensions

-------------------------
-- 880.933334ms
-- no issues found

The count is the extensions Atlas inspected, so an extension installed in pg_catalog, such as plpgsql, is not among them.

Scanning multiple databases

Pass --url more than once, or as a comma-separated list, to scan several databases in one run. A database that could not be reached is reported with the rest instead of ending the scan:

atlas security scan \
--url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" \
--url "postgres://postgres:pass@localhost:5433/reporting?sslmode=disable"
Scanning 2 databases for security issues:

-- postgres://postgres:xxxxx@localhost:5432/app?sslmode=disable (postgres 13.23):
-- Extension "pg_trgm" version "1.5" is vulnerable to CVE-2026-14678 (ELEVATED):
PostgreSQL pg_trgm picksplit reads past end of buffer. Upgrade the database engine to
version 14.24 or later to resolve CVE-2026-14678: the fix for extension "pg_trgm" ships in
engine releases
-- Extension "pgcrypto" version "1.3" is vulnerable to CVE-2026-14663 (ELEVATED):
PostgreSQL pgcrypto, for OpenSSL-disabled ciphers, silently encrypts to and decrypts
from cleartext. Upgrade the database engine to version 14.24 or later to resolve
CVE-2026-14663: the fix for extension "pgcrypto" ships in engine releases
-- Extension "pgcrypto" version "1.3" is vulnerable to CVE-2026-2005 (HIGH): PostgreSQL
pgcrypto heap buffer overflow executes arbitrary code. Upgrade the database engine to
version 14.21 or later to resolve CVE-2026-2005: the fix for extension "pgcrypto" ships in
engine releases

-- postgres://postgres:xxxxx@localhost:5433/reporting?sslmode=disable:
-- postgres: querying system variables: dial tcp [::1]:5433: connect: connection refused

-------------------------
-- 361.387208ms
-- 1 database could not be scanned
-- 3 issues found: 1 high, 2 elevated

A target that failed before its connection was opened carries no driver or version, which is why the second line above shows the URL alone.

note

The cve check currently covers PostgreSQL extensions. Scanning a database on an engine without extensions succeeds and reports no issues, so a scan can be pointed at a mixed fleet without special casing.

Failing the Command

The command exits with a non-zero status when a database could not be scanned, as that leaves its state unknown. The report already names the target and the reason, so nothing more is printed. The footer of the two-database scan above is the whole signal:

  -------------------------
-- 361.387208ms
-- 1 database could not be scanned
-- 3 issues found: 1 high, 2 elevated

The issues it reports do not fail it, as that run exits non-zero for the unreachable database, not for the three findings.

To gate on findings, set the lowest severity an issue must reach, either with --fail-on or with fail_on in the security block:

atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" --fail-on HIGH

An issue at HIGH or CRITICAL now exits non-zero, again with no output beyond the report. This keeps reporting separate from gating: a scheduled scan reports everything it finds while only HIGH and above break the build.

Severity Levels

The scan grades findings on the level the Security Graph assigns them, from lowest to highest:

NORMAL, ELEVATED, HIGH, CRITICAL

Filter the report with --min-severity, and drop individual records with --ignore. Both severity flags are case-insensitive:

atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" --min-severity HIGH
atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" --ignore "CVE-2026-14678,CVE-2026-14663"

The CVSS (Common Vulnerability Scoring System) rating of the underlying record stays available in the output as Severity, alongside the level the scan grades on. The two differ: a record the CVSS rated below HIGH, or did not rate at all, is graded ELEVATED, so it is reported rather than dropped.

note

security.cve grades on the Security Graph level, while the min_severity of the lint.cve analyzer grades on the CVSS rating (LOW, MEDIUM, HIGH, CRITICAL). The two blocks are configured separately, and the lint policy does not filter the scan.

Configuration

A security block in the atlas.hcl file configures the scan. Each check has its own block and runs when that block is present, and the cve check is included by default, so a security block with no check block still runs it:

atlas.hcl
env "prod" {
url = "postgres://postgres:pass@localhost:5432/app?sslmode=disable"
security {
min_severity = ELEVATED
fail_on = HIGH
cve {
ignore = ["CVE-2026-14678"]
}
}
}
atlas security scan --env prod

The url of the selected env is the database the scan runs against, so --url can be omitted. The exclude patterns of the env apply to the inspection the scan runs, so a pattern that hides an extension hides its findings too.

The security block takes the following attributes:

AttributeDescription
min_severityThe lowest level every check reports: NORMAL, ELEVATED, HIGH or CRITICAL. A check raises it for itself by setting its own, and never lowers it. All findings are reported when unset.
fail_onFail the command when an issue of this level or higher was reported. Issues are reported without failing the command when unset.

The flags take precedence over the block: --min-severity replaces the level shared by every check, and --fail-on replaces the configured one. --ignore is added to the identifiers the cve check already ignores rather than replacing them.

The cve check

The cve check reports installed extensions with known vulnerabilities, as recorded in the Atlas Security Graph:

atlas.hcl
security {
cve {
min_severity = HIGH
ignore = ["CVE-2026-14678"]
timeout = "10s"
}
}
AttributeDescription
min_severityThe lowest level this check reports. It applies when higher than the one the security block sets for every check.
ignoreCVE identifiers that are not reported.
timeoutTime limit for querying the Security Graph, 5s by default. A Security Graph that did not answer fails the scan rather than reporting a clean database.

The level a check reports at is the higher of its own min_severity and the one the security block sets for every check. In the following configuration, HIGH applies to the cve check and ELEVATED applies to any other check that is added:

atlas.hcl
security {
min_severity = ELEVATED
cve {
min_severity = HIGH
}
}

Project-level and env-level blocks

The block may be defined at the project level, inside an env, or both. An env block inherits the project one attribute by attribute, and the notify block is inherited as a whole:

atlas.hcl
security {
fail_on = HIGH
cve {
min_severity = ELEVATED
}
}

env "prod" {
url = "postgres://postgres:pass@localhost:5432/app?sslmode=disable"
// Inherits fail_on and cve.min_severity, and replaces the ignore list.
security {
cve {
ignore = ["CVE-2026-14678"]
}
}
}

Notifications

A notify block sends the result of the scan to HTTP endpoints, such as a Slack webhook:

atlas.hcl
variable "slack_webhook" {
type = string
}

env "prod" {
url = "postgres://postgres:pass@localhost:5432/app?sslmode=disable"
security {
notify {
on = [FINDINGS, FAILURE]
http "slack" {
url = var.slack_webhook
headers = { "Content-Type" = "application/json" }
body = jsonencode({
text = "${scan.count} vulnerable extensions (${scan.high} high, ${scan.elevated} elevated)"
})
}
}
}
}

The on attribute selects the events the result is sent on. It defaults to [FINDINGS, FAILURE], the events worth waking someone up for, so a clean scan sends nothing:

EventSent when
FINDINGSThe scan reported at least one issue.
FAILUREA database could not be scanned.
ALWAYSEvery scan, including a clean one.

A notify block requires at least one http block. The block may be repeated to notify more than one endpoint, and takes an optional name that Atlas uses when reporting an endpoint that failed:

Error: security.notify.http "slack": unexpected status 404: no such hook
AttributeDescription
urlThe URL for the request. Required. Supported schemes are http and https.
methodPOST (default), PUT or PATCH.
headersA map of request header field names and values.
bodyThe request body.
request_timeout_msThe request timeout in milliseconds, 10000 by default.
retryA block with attempts, min_delay_ms and max_delay_ms. attempts counts retries after the first try, and there are none by default.
ca_cert_pemCertificate Authority in PEM format.
client_cert_pemClient certificate in PEM format. Requires client_key_pem.
client_key_pemClient key in PEM format. Requires client_cert_pem.
insecureSkip verification of the server certificate chain and hostname. false by default.

The scan scope

The url, headers and body of an http block may interpolate the result of the scan:

ReferenceDescription
scan.countTotal number of issues reported.
scan.critical, scan.high, scan.elevated, scan.normalNumber of issues reported at each level. Always present, 0 included.
scan.failuresNumber of databases that could not be scanned.
scan.reportThe default report, without terminal styling.
scan.targetsThe scanned databases, each with url, driver, version, error, extensions and vulnerabilities.

Each entry of vulnerabilities holds id, name, version, level, severity, title, description, suggestion, and text for the line the default report prints. scan.targets is indexed, so a single-database scan reads its target as scan.targets[0].url. Forwarding the whole report is a single attribute:

atlas.hcl
notify {
on = [ALWAYS]
http {
url = var.slack_webhook
body = jsonencode({ text = scan.report })
}
}

Endpoints are notified in the order they are declared, before the command decides its exit code, so a scan that fails on fail_on or on an unreachable database still sends its notification. An endpoint that errors, or answers with a status outside the 2xx range, stops the remaining ones and fails the command with that error in place of the scan verdict, so keep retry on an endpoint whose availability should not decide the outcome of a scan.

Output Formats

--format renders the result with a Go template. There is no json shorthand: the json template function marshals whatever it is given, and a second argument indents it.

atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" \
--min-severity HIGH --format '{{ json . " " }}'
{
"Targets": [
{
"URL": "postgres://postgres:xxxxx@localhost:5432/app?sslmode=disable",
"Driver": "postgres",
"Version": "13.23",
"Extensions": [
"hstore",
"pg_trgm",
"pgcrypto"
],
"Vulnerabilities": [
{
"Name": "pgcrypto",
"Version": "1.3",
"ID": "CVE-2026-2005",
"Level": "HIGH",
"Severity": "HIGH",
"Title": "PostgreSQL pgcrypto heap buffer overflow executes arbitrary code",
"Description": "Heap buffer overflow in PostgreSQL pgcrypto allows a ciphertext provider to execute arbitrary code as the operating system user running the database. Versions before PostgreSQL 18.2, 17.8, 16.12, 15.16, and 14.21 are affected.",
"Suggestion": "Upgrade the database engine to version 14.21 or later to resolve CVE-2026-2005: the fix for extension \"pgcrypto\" ships in engine releases"
}
]
}
],
"Start": "2026-08-25T17:52:37.960585+07:00",
"End": "2026-08-25T17:52:38.356392+07:00"
}

Extensions lists every extension that was checked, so it stays complete even when --min-severity narrows Vulnerabilities, as it does here. Title and Description are omitted for a record that carries neither. A target that failed before its connection was opened carries only URL and Error. One that opened and failed during inspection carries Driver and Version as well.

Or report only what a dashboard needs:

atlas security scan --url "postgres://postgres:pass@localhost:5432/app?sslmode=disable" \
--format '{{ range .Targets }}{{ range .Vulnerabilities }}{{ println .ID .Level .Severity }}{{ end }}{{ end }}'
CVE-2026-14678 ELEVATED MEDIUM
CVE-2026-14663 ELEVATED MEDIUM
CVE-2026-2005 HIGH HIGH

The last two columns show the two scales side by side: both ELEVATED records are rated MEDIUM by CVSS.

The template scope holds the scan:

FieldDescription
.TargetsThe scanned databases: .URL, .Driver, .Version, .Extensions, .Vulnerabilities and .Error.
.CountTotal number of issues reported.
.LevelsIssue counts per level, highest first, omitting the empty ones: .Level and .Count.
.FailuresNumber of databases that could not be scanned.
.FailedWhether any database could not be scanned.
.Start, .EndWhen the scan started and ended.

Each vulnerability holds .Name, .Version, .ID, .Level, .Severity, .Title, .Description and .Suggestion, and .LevelText renders the line the default report prints.

Running on a Schedule

New CVEs are published for versions that are already installed, so the value of the scan comes from running it repeatedly rather than once. Both examples below pass the database URL from a secret and gate on HIGH. Point them at an --env instead once the target, the severity to fail on, and the endpoints to notify live in atlas.hcl.

The arigaio/atlas image has the Atlas binary as its entrypoint, so the manifest passes the command as args, and Kubernetes expands $(DATABASE_URL) from the container's environment:

cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: atlas-security-scan
spec:
schedule: "0 6 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
# A gated scan exits non-zero on findings, which is a verdict rather than
# a transient error. Without this, the job retries it six more times.
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: atlas
image: arigaio/atlas:latest
args: ["security", "scan", "--url", "$(DATABASE_URL)", "--fail-on", "HIGH"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: atlas-security-scan
key: database_url
# Read by the CLI itself, not passed as a flag.
- name: ATLAS_TOKEN
valueFrom:
secretKeyRef:
name: atlas-security-scan
key: atlas_token

The exit code is the whole signal here, so gate at the lowest severity you want to hear about. Anything the CVSS rated below HIGH is graded ELEVATED, so a job gated at HIGH stays green, and silent, on the most common finding there is. To receive the result itself rather than a pass or a fail, add a notify block, which lives in the security block of a config file.

Flags

FlagDescription
-u, --urlDatabase URL(s) to scan. Repeat the flag or pass a comma-separated list to scan more than one. Required, unless the selected env sets url.
--min-severityLowest severity to report: NORMAL, ELEVATED, HIGH or CRITICAL. Replaces the level the security block shares between its checks.
--fail-onLowest severity that fails the command. Takes precedence over fail_on.
--ignoreCVE identifier(s) not to report. Added to the ones the cve check ignores.
--formatGo template to use to format the output.
--envEnv from the config file to use.
-c, --configConfig (project) file URL. file://atlas.hcl by default.
--varInput variables for the config file.

Next Steps