CloudSentinel is a cloud security posture management (CSPM) scanner. It inspects AWS and Azure resources for common misconfigurations, maps every finding to a CIS Benchmark control ID, and produces JSON and Markdown reports you can read locally or wire into CI as a pass/fail gate.
It's read-only: CloudSentinel never modifies the resources it scans.
The same scanner, run against two Terraform stacks. Against infra/vulnerable it reports 6
critical findings and exits 1 (GATE FAILED); against infra/remediated it reports no
critical findings and exits 0 (Gate passed). Nothing about the scanner changes between the
two runs — only the infrastructure it is pointed at.
flowchart TD
subgraph AWSSCAN["AWS scanners"]
S3["S3"]
IAM["IAM"]
CT["CloudTrail"]
SG["Security Groups"]
end
subgraph AZSCAN["Azure scanners"]
ST["Storage Accounts"]
NSG["Network Security Groups"]
end
AGG["Aggregator<br/>dedup + severity ordering"]
REP["Reporter<br/>JSON + Markdown + console"]
GATE{"CI gate<br/>--fail-on threshold"}
PASS["exit 0"]
FAIL["exit 1"]
S3 --> AGG
IAM --> AGG
CT --> AGG
SG --> AGG
ST --> AGG
NSG --> AGG
AGG --> REP
REP --> GATE
GATE -->|"nothing at or above threshold"| PASS
GATE -->|"findings at or above threshold"| FAIL
Each scanner returns findings independently. The aggregator deduplicates them by
(resource, check) and orders them by severity; the reporter renders the same finding set three
ways (JSON, Markdown, console); the gate compares the highest severity present against
--fail-on and chooses the exit code.
AWS checks (mapped to the CIS Amazon Web Services Foundations Benchmark):
- S3 — public access block, public ACLs, public bucket policies, encryption at rest
- IAM — wildcard-action policies, unused roles, root account MFA, access key age
- CloudTrail — trail existence, logging state, log file validation, trail bucket encryption
- Security Groups — SSH/RDP open to the world, unrestricted egress, overly permissive port ranges
Azure checks (mapped to the CIS Microsoft Azure Foundations Benchmark v1.4.0, released
11-26-2021 — control numbers shift between benchmark versions, so this version is pinned
explicitly in scanner/reporter.py):
- Storage Accounts — public blob access, secure transfer (HTTPS-only), customer-managed key encryption, default network access rule
- Network Security Groups — SSH/RDP open to the world, wide inbound port ranges, unrestricted egress
Checks without a clean matching control in the pinned benchmark version are left unmapped ("N/A" in reports) rather than assigned a plausible-looking but incorrect number.
Every finding carries a severity (critical, high, medium, low), and CloudSentinel can
gate a CI pipeline by exiting non-zero when findings at or above a chosen severity exist.
docker build -t cloudsentinel .
docker run --rm cloudsentinel --helpCredentials and configuration (AWS credentials/region, Azure auth, endpoint URLs) are never
baked into the image — pass them at docker run time via environment variables or CLI flags,
the same way you would when running main.py directly. For example, to scan against
LocalStack:
docker run --rm \
-e AWS_ACCESS_KEY_ID=test \
-e AWS_SECRET_ACCESS_KEY=test \
-e AWS_DEFAULT_REGION=us-east-1 \
--network host \
cloudsentinel --cloud aws --endpoint-url http://localhost:4566python3 -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install -r requirements.txt
python main.py --helpCLI flags (from main.py):
| Flag | Description |
|---|---|
--cloud {aws,azure,all} |
Which cloud(s) to scan. Default: aws. |
--region REGION |
AWS region to scan. Default: us-east-1. (Azure resources use their own location, not this flag.) |
--subscription-id ID |
Azure subscription ID. Required when --cloud is azure or all. |
--endpoint-url URL |
Custom AWS endpoint (e.g. LocalStack) instead of real AWS. |
--fail-on {critical,high,medium,low} |
Severity threshold that causes a non-zero exit. Default: critical. |
--output-dir DIR |
Where to write the JSON/Markdown reports. Default: ./reports. |
--exclude-checks CHECK_IDS |
Comma-separated check IDs to exclude from findings and the fail-on gate. |
--db-url URL |
PostgreSQL URL to persist results to (falls back to DATABASE_URL). Off when neither is set. See Persistence & Drift Detection. |
--diff |
Print drift versus the previous stored scan. Requires a database. |
Examples:
# Scan AWS against real AWS credentials in the environment
python main.py --cloud aws --region us-east-1
# Scan AWS against LocalStack
python main.py --cloud aws --endpoint-url http://localhost:4566
# Scan Azure
python main.py --cloud azure --subscription-id <subscription-id>
# Scan both clouds, exclude two known-noisy checks, fail on high severity or above
python main.py --cloud all --subscription-id <subscription-id> \
--exclude-checks CLOUDTRAIL_NO_TRAIL,IAM_ROOT_NO_MFA --fail-on highExit codes: 0 = no findings at/above the --fail-on threshold, 1 = gate failed,
2 = scan error (e.g. missing --subscription-id, credential failure).
Persistence is opt-in. When --db-url or DATABASE_URL is set, each scan is written to
PostgreSQL (scanner/db.py: psycopg v3, hand-written parameterized SQL, no
ORM). When neither is set, CloudSentinel makes no database connection, which is the default.
What gets stored:
- Each provider in a run gets its own
scansrow, so--cloud allwrites two rows. - The AWS account ID comes from
sts:GetCallerIdentity. The Azure account ID is--subscription-id, which is required whenever Azure results are persisted. - Only FAIL findings are stored, after
--exclude-checksis applied. - A connection failure reports only the host and database name. The URL, and any password in it, is never printed.
CREATE TABLE scans (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL CHECK (provider IN ('aws', 'azure')),
account_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE findings (
id BIGSERIAL PRIMARY KEY,
scan_id BIGINT NOT NULL REFERENCES scans (id) ON DELETE CASCADE,
check_id TEXT NOT NULL,
check_name TEXT NOT NULL,
cis_control TEXT, -- NULL for checks with no CIS mapping
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL,
region TEXT NOT NULL,
details JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (scan_id, check_id, resource_id)
);
CREATE INDEX findings_check_resource_idx ON findings (check_id, resource_id);
CREATE INDEX findings_severity_idx ON findings (severity);
CREATE INDEX scans_provider_account_idx ON scans (provider, account_id, started_at DESC);The (check_id, resource_id) index exists so you can look up one finding's history across
every scan (for example, "since when has this security group had SSH open?"). The UNIQUE
index can't serve that query because its first column is scan_id.
--diff compares the scan that just ran with the previous scan for the same provider and
account. It finds NEW and RESOLVED findings with SQL EXCEPT and STILL OPEN findings with
INTERSECT. Findings are matched on (check_id, resource_id), so a severity change on the
same resource counts as STILL OPEN, shown at its current severity. --exclude-checks findings
are never stored, so changing that list between runs shows up as drift.
Drift — cloud: aws | account: 123456789012 | scan #5 vs #4
NEW 2
RESOLVED 1
STILL OPEN 2
NEW (2):
HIGH S3_PUBLIC_ACCESS_BLOCK 2.1.5 arn:aws:s3:::drift-demo-bucket
MEDIUM S3_ENCRYPTION_AT_REST 2.1.1 arn:aws:s3:::drift-demo-bucket
RESOLVED (1):
MEDIUM SG_UNRESTRICTED_EGRESS 5.4 sg-d33c2dc18d190e7dc
STILL OPEN (2):
CRITICAL CLOUDTRAIL_NO_TRAIL 3.1 aws:cloudtrail:us-east-1:no-trail
CRITICAL IAM_ROOT_NO_MFA 1.5 arn:aws:iam::root
On the first stored scan for a provider and account, --diff prints No previous scan to compare and doesn't report every finding as NEW. --diff never changes the exit code, which
still comes only from --fail-on.
cp .env.example .env # set POSTGRES_PASSWORD and EXPORTER_DB_PASSWORD; .env is gitignored
docker compose up --build # postgres:16, the scanner (runs --cloud aws --diff), exporter, PrometheusThe compose file has no hardcoded passwords: POSTGRES_PASSWORD and EXPORTER_DB_PASSWORD
must be set. The scanner
receives it as PGPASSWORD so it never appears in DATABASE_URL. The scanner container
starts only once Postgres passes its healthcheck.
The migration runs automatically because ./migrations is mounted into
/docker-entrypoint-initdb.d. That hook runs only when the data volume is empty. For
future migrations on an existing volume, either apply them by hand
(docker compose exec -T postgres psql -U cloudsentinel -d cloudsentinel < migrations/002_x.sql)
or reset the volume with docker compose down -v, which deletes all stored scans.
exporter/ is a small Go service that reads stored scans from PostgreSQL and exposes them as
Prometheus metrics, so findings can be graphed and alerted on
instead of read one report at a time. It queries the database on every scrape (no cache,
5-second timeout) and serves /metrics and /healthz on :9187 (LISTEN_ADDR).
All per-account metrics come from the latest scan for each (provider, account_id).
| Metric | Labels | Meaning |
|---|---|---|
cloudsentinel_findings |
provider, account_id, severity |
FAIL findings in the latest scan. All four severities are always present (0 if none). |
cloudsentinel_last_scan_timestamp_seconds |
provider, account_id |
Unix time the latest scan started. |
cloudsentinel_drift_findings |
provider, account_id, status (new/resolved) |
Findings that appeared in, or disappeared from, the latest scan compared with the previous one. Matched on (check_id, resource_id), exactly like --diff; absent until an account has two scans. |
cloudsentinel_exporter_up |
— | 1 if the last database query succeeded, 0 otherwise. |
All queries for one scrape run in a single read-only REPEATABLE READ transaction, so a scan
that commits mid-scrape can't leave the metrics disagreeing with each other. If the database
is unreachable, the scrape returns only cloudsentinel_exporter_up 0, not stale numbers.
# Critical findings per account
sum by (provider, account_id) (cloudsentinel_findings{severity="critical"})
# Total open findings across everything
sum(cloudsentinel_findings)
# Accounts whose latest scan introduced new findings
cloudsentinel_drift_findings{status="new"} > 0
# Hours since each account was last scanned (alert when this grows too large)
(time() - cloudsentinel_last_scan_timestamp_seconds) / 3600
# Exporter can't reach the database
cloudsentinel_exporter_up == 0
With Docker Compose (above), the exporter and prometheus services start with the rest of
the stack. Open the Prometheus UI at http://127.0.0.1:9090 and check Status → Targets,
where cloudsentinel should be UP. For network exposure:
- The exporter's port is not published to the host. Prometheus scrapes
exporter:9187on the compose network. - Prometheus is bound to 127.0.0.1:9090 only, so it isn't reachable from other machines.
- The exporter image is a static binary on
distroless/static-debian12:nonroot. It has no shell, runs as a non-root user and has a read-only filesystem.
To run it outside Docker (Go 1.27+):
cd exporter
export DATABASE_URL=postgresql://cloudsentinel_exporter@localhost:5432/cloudsentinel
export PGPASSWORD=... # the EXPORTER_DB_PASSWORD you chose
go run .
curl -s localhost:9187/metrics | grep ^cloudsentinelConfiguration comes from environment variables: DATABASE_URL (no password), PGPASSWORD
and LISTEN_ADDR. Logs are structured JSON (log/slog). They identify the database only by
host and dbname and never include the password or connection URL.
The exporter connects as cloudsentinel_exporter, created by
migrations/002_exporter_role.sql, not as the database
owner. The role has exactly CONNECT on the database, USAGE on schema public, and
SELECT on scans and findings, and nothing else. The exporter is the part of the stack
exposed over HTTP and runs continuously, so it is the most likely component to be buggy or
compromised. With a read-only role, even a SQL bug or a stolen credential can't alter or
delete scan history, and the database enforces this rather than the exporter's own code.
The Go integration tests assert that the role gets permission denied on
INSERT/UPDATE/DELETE.
The role's password comes from EXPORTER_DB_PASSWORD (read by psql's \getenv) and is
never stored in the repo. The migration refuses to run if that variable is unset. On a fresh
volume it runs automatically. On an existing volume, apply it by hand:
docker compose exec -T postgres \
psql -v ON_ERROR_STOP=1 -U cloudsentinel -d cloudsentinel -f /docker-entrypoint-initdb.d/002_exporter_role.sqlThis relies on EXPORTER_DB_PASSWORD being set in .env, because compose passes it into the
postgres container. The migration is idempotent, so running it again is safe.
cd exporter
go vet ./... && go test ./... # unit tests; DB tests skip
docker run -d --rm --name cs-pg -p 55432:5432 -e POSTGRES_USER=cloudsentinel \
-e POSTGRES_PASSWORD=localtest -e EXPORTER_DB_PASSWORD=exportertest \
-v "$PWD/../migrations:/docker-entrypoint-initdb.d:ro" postgres:16
TEST_DATABASE_URL=postgresql://cloudsentinel:localtest@localhost:55432/cloudsentinel go test -v ./...The integration tests seed two scans in a throwaway schema and compare every metric value exactly. They also check the read-only role's grants.
Both reports from the run shown in the GIF above are committed, so you can read the real output rather than an excerpt of it:
demo/sample-report-vulnerable.md— theinfra/vulnerablestack: 14 findings (6 critical, 3 high, 5 medium), gate failed, exit 1.demo/sample-report-remediated.md— theinfra/remediatedstack: 2 findings (2 medium), gate passed, exit 0.
A trimmed excerpt from the remediated report:
# CloudSentinel Report
Generated: 2026-08-15 11:34:03 UTC
## Summary
| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 0 |
| Medium | 2 |
| Low | 0 |
| **Total** | **2** |
## Findings
### Medium (2)
#### Security group allows unrestricted egress to 0.0.0.0/0
- **Check ID:** `SG_UNRESTRICTED_EGRESS`
- **CIS Control:** 5.4
- **Resource:** `sg-4b454c530d4b90c6e`
- **Region:** us-east-1
- **Remediation:** Restrict egress rules to specific destinations and ports.The JSON report alongside it contains the same data (summary + full findings list) in
machine-readable form.
pip install -r requirements.txt
pytest tests/ -v82 tests cover every AWS check against moto-mocked AWS
services, and every Azure check against a mocked Azure SDK client (unittest.mock), each with
both a misconfigured and a compliant case.
tests/test_db.py adds persistence and drift tests. The ones that need a database run only
when TEST_DATABASE_URL points at a PostgreSQL server; otherwise they are skipped. Each test
uses its own throwaway schema:
docker run -d --rm -p 55432:5432 -e POSTGRES_PASSWORD=localtest postgres:16
TEST_DATABASE_URL=postgresql://postgres:localtest@localhost:55432/postgres pytest tests/ -vGitHub Actions runs on every push and pull request to main:
.github/workflows/checkov.yml— static analysis of the Terraform ininfra/with Checkov, verifying the vulnerable config is flagged and the remediated config is clean..github/workflows/scan.yml(testsjob) — runs the full pytest suite against apostgres:16service container, including the PostgreSQL-backed tests..github/workflows/scan.yml(exporterjob) —gofmtcheck,go vet,go test -race(with migrations applied to apostgres:16service, so the integration tests run) andgovulncheckfor the Go exporter..github/workflows/scan.yml— spins up LocalStack, applies theinfra/vulnerableandinfra/remediatedTerraform configs in turn, and runs CloudSentinel against each, verifying the expected exit code (1 for vulnerable, 0 for remediated).
The workflow asserts its own expected exit codes rather than just running the scanner: it requires exit 1 on the vulnerable stack and exit 0 on the remediated one, so a regression in either direction fails CI — both the failing path and the passing path are tested. The findings counts produced in CI match the local run shown in the GIF exactly: 14 total, 6 critical, 3 high, 5 medium.
See infra/README.md for the documented LocalStack quirks that require
excluding CLOUDTRAIL_NO_TRAIL and IAM_ROOT_NO_MFA from the CI runtime scan (LocalStack's
free tier doesn't support trail creation or root MFA simulation) — this is a LocalStack
limitation, not a gap in scanner coverage; both checks are still exercised by the unit test
suite.
MIT — see LICENSE.

