Continuously scan infrastructure and code for compliance violations. Covers policy-as-code, CIS benchmarks, automated remediation, audit evidence generation, and the patterns that shift compliance from periodic audits to continuous verification.
Automated compliance scanning is the process of systematically evaluating systems, configurations, data, and workflows against a set of compliance standards—such as GDPR, HIPAA, SOC 2, or PCI-DSS—using scripts, tools, and pipelines that execute checks with minimal human intervention. It matters when systems must be continuously audited, when regulatory deadlines are tight, and when drift from policy—like a server missing a TLS configuration or a database column missing a retention rule—must be detected and remediated before an audit fails.
Compliance scanning begins with defining a set of rules that describe what must be true for a system to meet a standard. These rules are typically expressed as assertions over infrastructure, data, and processes. A rule may check that a file exists, a service is running, a database table has a specific schema, or a message in a queue has a required field.
Each rule is evaluated by a scanner that executes a set of probes—small, idempotent checks—against a target. Probes may run locally, remotely, or via APIs. The scanner collects results and reports a pass/fail status per rule, along with metadata such as the scan timestamp, the environment, and the rule source.
For example, a rule to verify that all user data in a database is encrypted at rest might be defined as:
{
"rule_id": "db-encryption-at-rest",
"title": "All user data tables are encrypted at rest",
"description": "Ensure that all tables in the users schema are encrypted using AES-256.",
"severity": "high",
"check": {
"type": "sql",
"query": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'users' AND table_type = 'BASE TABLE';",
"expected": {
"count": 5,
"rows": [
{ "table_name": "users" },
{ "table_name": "profiles" },
{ "table_name": "preferences" },
{ "table_name": "sessions" },
{ "table_name": "activity_log" }
]
}
},
"remediation": "Run `pg_encrypt_tables --schema users --algorithm aes-256`."
}
This rule is evaluated by a scanner that runs the SQL query against a PostgreSQL instance and compares the result to the expected output. The scanner may use psql to execute the query and jq to parse the result:
psql -At -d myapp -c '
SELECT table_name FROM information_schema.tables
WHERE table_schema = ''users'' AND table_type = ''BASE TABLE''
' | jq -R 'split("\n") | .[] | {table_name: .}' > /tmp/tables.json
if [ $(jq -c 'length' < /tmp/tables.json) -ne 5 ]; then
echo "Failed: expected 5 tables, got $(jq -c 'length' < /tmp/tables.json)"
exit 1
fi
# Verify specific table names
for table in users profiles preferences sessions activity_log; do
if ! jq -e --arg t "$table" '.[] | select(.table_name == $t)' < /tmp/tables.json > /dev/null; then
echo "Failed: table $table missing"
exit 1
fi
done
The scanner reports the result as a JSON object:
{
"scan_id": "2024-03-15-08-22-41",
"timestamp": "2024-03-15T08:22:41Z",
"environment": "production",
"rules": [
{
"rule_id": "db-encryption-at-rest",
"status": "pass",
"details": {
"query": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'users' AND table_type = 'BASE TABLE'",
"actual": [
{ "table_name": "users" },
{ "table_name": "profiles" },
{ "table_name": "preferences" },
{ "table_name": "sessions" },
{ "table_name": "activity_log" }
],
"expected": {
"count": 5,
"rows": [
{ "table_name": "users" },
{ "table_name": "profiles" },
{ "table_name": "preferences" },
{ "table_name": "sessions" },
{ "table_name": "activity_log" }
]
}
}
}
]
}
Scans are most effective when they are part of a continuous integration or continuous delivery pipeline. A compliance scanner runs automatically on every commit, merge, or deployment, ensuring that changes to infrastructure or code do not introduce compliance drift.
A typical CI step using curl, jq, and bash checks that a new release satisfies compliance requirements:
# .github/workflows/compliance.yml
name: Compliance Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run compliance scan
run: |
# Build and deploy the app
make build deploy
# Run compliance scanner
curl -s https://scanner.example.com/api/v1/scans \
-X POST \
-H "Content-Type: application/json" \
-d '{
"environment": "staging",
"commit": "abc1234",
"target": "https://staging.myapp.com"
}' \
| jq -r '.scan_id' > .scan_id
# Fetch results
scan_id=$(cat .scan_id)
curl -s "https://scanner.example.com/api/v1/scans/$scan_id" \
| jq -c '.rules[] | select(.status == "fail")' \
> failed_rules.json
# Fail the job if any rule fails
if [ -s failed_rules.json ]; then
echo "Compliance scan failed:"
jq -r '.[].rule_id' failed_rules.json
exit 1
fi
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: compliance-results
path: |
.scan_id
failed_rules.json
scan_results.json
The scanner exposes a REST API to start scans and retrieve results. The POST /api/v1/scans endpoint accepts a JSON payload with scan configuration and returns a scan_id. The scanner stores the scan in a database and returns the ID immediately. The GET /api/v1/scans/{id} endpoint returns the full scan report, including a status field that can be pending, running, completed, or failed.
A common failure mode is that the scanner does not update the scan status on failure. A pipeline may assume the scan is complete, but the scanner returns completed while the underlying checks are still running. To detect this, the pipeline should poll the /api/v1/scans/{id} endpoint until the status is completed:
scan_id=$(curl -s https://scanner.example.com/api/v1/scans \
-X POST \
-d '{"environment": "staging"}' \
| jq -r '.scan_id')
while true; do
status=$(curl -s "https://scanner.example.com/api/v1/scans/$scan_id" | jq -r '.status')
if [ "$status" = "completed" ]; then
break
fi
sleep 10
done
Scans are only valuable if their results are visible, actionable, and persistent. A compliance scanner must store scan results over time and provide tools to detect drift—changes in compliance status across time.
The scanner maintains a scans table in a database with columns: id, environment, commit, timestamp, status, and results (JSON). Each scan is linked to a set of rules and their outcomes.
A common pattern is to compute a compliance score per environment:
SELECT
environment,
COUNT(*) AS total_rules,
SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) AS passed_rules,
ROUND(
(SUM(CASE WHEN status = 'pass' THEN 1 ELSE 0 END) * 100.0) /
COUNT(*),
2
) AS compliance_percentage
FROM scans
JOIN scan_rules ON scans.id = scan_rules.scan_id
WHERE scans.timestamp >= NOW() - INTERVAL '7 days'
GROUP BY environment;
This query returns a summary of compliance across environments, enabling teams to track progress and detect regression.
Drift is detected by comparing scan results from two consecutive runs. A script computes the difference between two scans:
# Compare two scan IDs
compare_scans() {
local base_id="$1"
local head_id="$2"
local output_file="$3"
# Fetch both scans
curl -s "https://scanner.example.com/api/v1/scans/$base_id" \
| jq -c '.rules[] | .rule_id, .status as $status' > /tmp/base.json
curl -s "https://scanner.example.com/api/v1/scans/$head_id" \
| jq -c '.rules[] | .rule_id, .status as $status' > /tmp/head.json
# Compare using jq
jq -n '
[
inputs,
inputs
] |
reduce .[0] as $base ({}; .[$base.rule_id] = $base.status) |
reduce .[1] as $head (.; .[$head.rule_id] = $head.status) |
. as $map |
[ keys_unsorted | .[] | {
rule_id: .,
base_status: $map[.],
head_status: $map[.],
status_changed: $map[.] | [ $map[.] | .[0] ] | .[1] != .[0]
} ] |
sort_by(.rule_id) |
. as $results |
{
total_rules: length,
changed_rules: [ $results[] | select(.status_changed) ],
new_rules: [ $results[] | select(.base_status == null) ],
lost_rules: [ $results[] | select(.head_status == null) ],
compliance_delta: (
$results | [ .[] | select(.status_changed) | .rule_id ] | length
)
}' \
/tmp/base.json /tmp/head.json \
> "$output_file"
}
This script produces a JSON report showing which rules have changed status, which are new, and which have been lost. It is used to generate reports, trigger alerts, and feed dashboards.
A scan fails when one or more rules return fail. The scanner must distinguish between transient and persistent failures. A rule may fail due to a temporary outage, a configuration mismatch, or a missing file.
A rule can include a retry configuration to handle transient failures:
{
"rule_id": "api-service-health",
"title": "API service is healthy",
"check": {
"type": "http",
"url": "https://api.example.com/health",
"method": "GET",
"expected_status": 200,
"timeout": 5,
"retry": {
"count": 3,
"delay": 2,
"backoff": "exponential"
}
}
}
The scanner retries the HTTP request up to three times with exponential backoff. If the first attempt times out, it waits two seconds before retrying, then four, then eight. It returns fail only if all attempts fail.
Another common failure mode is incorrect rule configuration. For example, a rule expects a file to have a specific content, but the expected content is a JSON object while the actual file is a flat string.
A rule may define a validation function to process the raw result:
{
"rule_id": "config-file-valid",
"title": "Config file is valid JSON",
"check": {
"type": "file",
"path": "/etc/app/config.yaml",
"expected": {
"content": "{ \"database\": { \"host\": \"db.example.com\" } }"
},
"validation": {
"type": "json",
"schema": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer" }
},
"required": ["host"]
}
},
"required": ["database"]
}
}
}
}
The scanner reads the file, validates it against the schema, and returns fail if validation fails. The error message includes the validation failures:
{
"rule_id": "config-file-valid",
"status": "fail",
"details": {
"actual": {
"content": "{ \"database\": { \"host\": \"db.example.com\" } }",
"format": "yaml"
},
"errors": [
{
"path": "database.port",
"message": "Required property 'port' is missing"
},
{
"path": "database.host",
"message": "Expected type 'string' but found 'string'"
}
]
}
}
A silent failure occurs when a rule expects a list but receives a string, or when a boolean value is true but the rule expects 1. The scanner should validate the structure of the expected output against the actual result, not just compare values.
Scans may be re-run manually or automatically to verify fixes. A common pattern is to re-run a scan after a failed deployment:
# Re-run scan for a failed deployment
curl -s https://scanner.example.com/api/v1/scans \
-X POST \
-H "Content-Type: application/json" \
-d '{
"environment": "production",
"commit": "d4e8b2a",
"target": "https://prod.myapp.com",
"retries": 2,
"retry_delay": 30,
"skip_existing": true
}'
The retries field causes the scanner to re-run the scan if it fails, with a delay between attempts. The skip_existing flag prevents re-running scans for the same commit if a scan already exists.
A scan may be triggered by a webhook or a cron job. The scanner listens for POST /webhook events and executes scans based on the payload:
{
"event": "deployment",
"environment": "staging",
"commit": "abc1234",
"target": "https://staging.myapp.com"
}
The scanner creates a new scan and returns a scan_id. The webhook client can poll for the scan result or subscribe to a WebSocket stream of updates.
A sharp edge is that the scanner may not detect when a rule has been updated in the configuration. A rule may change its query, expected values, or validation logic, but the scanner continues to use the old version. To fix this, the scanner should compute a rule_hash from the rule definition and store it with the scan. When the rule hash changes, the scanner marks the scan as using a new rule version.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy