Monitor and track expiring secrets across your Vault infrastructure
Chronowarden is a secret lifecycle observability service that syncs with your secret providers, tracking TTLs and rotation requirements through custom metadata. It provides a web UI and REST API to visualize secret health and alert on expiring credentials.
This is very early work being built with focus on compliance for financial institutions (PCI DSS 4.0, DORA) and best practices (NIST SP 800-63B-4) regarding credential rotation.
Warning
The API and UI have no authentication yet (planned for a later release). Anyone who can reach the HTTP port
can read all tracked metadata (including other teams' custom metadata values kept in the audit log) and trigger
syncs. Run Chronowarden only on a trusted network or behind an authenticating reverse proxy, and never expose it
directly to the internet. Set allowed_hosts in config.yaml to the names you serve it under: other web pages
then can't reach it through DNS rebinding (cross-site writes are refused either way).
Join us on Matrix to discuss and troubleshoot!
- Vendor neutrality - Connect to multiple backends instances simultaneously
- Credential Health Dashboard - Visual status of secrets (OK, Warning, Expired, No TTL)
- Compliance Evidence - Privileged flag (
chronowarden_privileged), gap warnings, opt-in reporting periods and frozen, SHA-256 hashed inventory snapshots with CSV/JSON export; a Compliance tab with summaries and a tree - Compliance Policies and Reports - Your own policies in
config.yaml(rotation age, expiry, owner, system, confirmed rotations) with green/amber/red results per control, their trend across snapshots, and a PDF/A report for every snapshot - Audit Log - Append-only, hash-chained record of every change (assignments, systems, owners, removed secrets, config), exportable as JSON Lines
- Systems and Owners - Assign secrets to business systems (CMDB link, asset number) and owners, and see who is responsible for each secret
- Severity Levels - Classify secrets by (user-defined) compliance requirements (PCI-DSS, Critical, Default)
- Secure - Never reads actual secret values, only metadata
- Scheduled Sync - Every vault is synced every
polling_interval(default 6h) and on demand;python -m chronowarden syncruns one sync and exits, e.g. as a Kubernetes CronJob - Webhook Alerts - Alertmanager-compatible webhooks to each secret's owners (expiring, expired, rotated, unconfirmed) and to default routes (vault down, compliance), with retries, a delivery log and Standard Webhooks signing
- Prometheus Metrics - Optional (
metrics.enabled: true): per-secret expiry and rotation series for alert rules, API and Vault call metrics - Modern Web UI - SvelteKit frontend with dark mode
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Vaults │ ───► │ Chronowarden │ ───► │ Web UI │
│ (Multiple) │ │ Backend │ │ (Svelte) │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌──────────────┐
│ SQLite DB │
│ (Metadata) │
└──────────────┘
docker run --network=host --rm -p 8000:8000 \
-v $(pwd)/config.yaml:/data/config.yaml \
ghcr.io/fajfer/chronowarden:v0.8.0Add -v $(pwd)/chronowarden.db:/app/chronowarden.db to keep the database. From 0.7 it holds system and owner
assignments that can't be rebuilt from Vault, so back up chronowarden.db; it is upgraded automatically on
startup.
Each release publishes three images for amd64 and arm64:
| Tag | Contents | Size |
|---|---|---|
v<version> |
Production (default): distroless, no shell, UI and PDF reports | ~165 MB |
v<version>-headless (from 0.8) |
API and sync only: no UI, no PDF reports (report.pdf answers 501) |
~82 MB |
v<version>-dev |
Full shell, git, --reload |
~430 MB |
The production and headless images are signed and carry SLSA provenance and SBOM attestations.
Production (default)
docker build -t fajfer/chronowarden:0.8.0 .
Headless
docker build --target headless -t fajfer/chronowarden:0.8.0-headless .
Development
docker build --target dev -t fajfer/chronowarden:0.8.0-dev .
- Python 3.12+ (tested on 3.13.5)
- Node.js 18+ (for frontend)
- Docker (optional, for dev Vaults)
-
Install dependencies:
uv sync
-
Configure vaults:
Copy the example config and add your Vault instances:
cp config.example.yaml config.yaml
Edit
config.yaml:vaults: - name: production address: https://vault.example.com:8200 token_env: VAULT_PRODUCTION_TOKEN # or token_file / token; AppRole is also supported mount_path: secret verify_ssl: true severity: critical # default severity for every engine in this vault expiry_profiles: # built-in profiles shown; add your own or override them default: rotation_period: "365d" critical: rotation_period: "6m" pci-dss-4.0: rotation_period: "90d"
See
config.example.yamlfor every option, including per-engine and per-secret severity overrides. The config is validated strictly: unknown keys, unknown severities and invalid values stop the server at startup with a message naming each problem. -
Run the server:
uv run uvicorn chronowarden:app --reload
API will be available at
http://localhost:8000
-
Install dependencies:
cd frontend npm install -
Run development server:
npm run dev
UI will be available at
http://localhost:5173Optionally set
VITE_API_BASE_URLto point the frontend API client at a custom base path (defaults to/api/v1).
For local testing with dev Vault instances (podman preferred, docker works too):
uv run python dev-setup.pyThis script:
- starts OpenBao (port 8200) and HashiCorp Vault (port 8201) dev servers with podman, or docker if podman isn't
installed (
CHRONOWARDEN_CONTAINER_RUNTIME=dockerto choose); - seeds three KV v2 engines with sample secrets and an AppRole with the policy below;
- writes
config.yamlfor both (only if it doesn't exist or was written bydev-setup.py;--forcereplaces another one,--config PATHwrites elsewhere) and prints the dev root tokens.
Cleanup (stops and removes both dev containers):
uv run python dev-setup.py --cleanupIntegration tests run Chronowarden against real OpenBao and Vault containers (testcontainers); CI runs them too:
uv run pytest -m integrationWith podman, point testcontainers at its API socket: DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
(systemctl --user start podman.socket) and TESTCONTAINERS_RYUK_DISABLED=true.
Chronowarden requires read/write access to secret metadata only. It never reads actual secret values.
Required capabilities:
# For KV v2 engines
path "+/metadata/*" {
capabilities = ["list", "read", "update"]
}
path "+/metadata" {
capabilities = ["list"]
}
# Engine discovery
path "sys/mounts" {
capabilities = ["read"]
}Custom metadata fields (written by Chronowarden from config.yaml, which is the source of truth):
chronowarden_ttl- Expiry date: the secret's last update plus its profile's rotation periodchronowarden_severity- Severity level resolved from config.nonemeans tracked but never rotatedchronowarden_privileged- Written only when someone marks a secret privileged in Chronowarden (or reverts that); you can also set it yourself (true)
Chronowarden merges these keys into the existing metadata; other keys and settings stay as they are. It reads the metadata and writes it back, so a change someone makes to the same secret's custom metadata between that read and write is lost (Vault has no check-and-set for metadata).
GET /api/v1/secrets- List all tracked secrets with metadata- Query params:
vault_name,engine_id,severity,system_id,owner_id(effective owner),unassigned,include_removed(secrets deleted in the backend are hidden by default)
- Query params:
GET /api/v1/secrets/{id}- Get secret metadata by ID, including its system, owners and effective ownersPUT /api/v1/secrets/{id}/system- Assign the secret to a system ({"system_id": "..."}, ornullto unassign)PUT /api/v1/secrets/{id}/owners- Replace the secret's owners ({"owner_ids": [...]})POST /api/v1/secrets/{id}/confirmations- Confirm the newest version is applied ({"applied_at": "...", "confirmed_by": "pipeline"}); the next rotation counts from that date if it is later than the backend updateDELETE /api/v1/secrets/{id},DELETE /api/v1/secrets/removed- Purge secrets that sync no longer finds in their backend, with their assignments (removedtakesvault_name/engine_id); a secret that still exists gives409
Severity is set in config.yaml only (per vault, engine or secret). A secret's effective owners are its own owners,
or else its system's owners.
GET/POST /api/v1/systems/,GET/PUT/DELETE /api/v1/systems/{id}- Systems (name, description, CMDB URI, asset number); deleting a system that still has secrets returns409with their listPUT /api/v1/systems/{id}/owners- Replace a system's ownersGET/POST /api/v1/owners/,GET/PUT/DELETE /api/v1/owners/{id}- Owners; deleting one removes it from every secret and system
The server syncs every connected vault shortly after startup and then every polling_interval (30m, 6h, 1d;
minimum 1m; off turns it off). To sync from a CronJob instead, set polling_interval: off on the server and run
python -m chronowarden sync (or the chronowarden sync script) in the same working directory, so both use the
same chronowarden.db. It exits 0 when every vault synced, 1 when one was unreachable or failed, and 2 on a
configuration error.
POST /api/v1/sync/vault/{vault_name}- Trigger synchronization for a specific Vault instance- Scans the specified vault instance and updates local cache
- A disconnected vault is reconnected first. If that fails, the response is
503with thereason(offline,auth, ...) and whether a background retry is scheduled; the vault is then synced automatically as soon as the retry reconnects it
GET /api/v1/alerts- Alerts firing nowGET /api/v1/webhooks/deliveries- Webhook delivery log (no payloads, no URLs)POST/DELETE /api/v1/owners/{id}/routes/{route_id}/signing- Turn Standard Webhooks signing on (the secret is returned once) or offPOST /api/v1/owners/{id}/test-route/{route_id}- Send a test webhook now
PUT /api/v1/secrets/{id}/privileged- Setchronowarden_privilegedin the backend ({"privileged": true})GET /api/v1/compliance/summary- Total and privileged secrets per vault, engine and systemGET /api/v1/compliance/gaps- Secrets without owner or system, expired or without TTL, privileged without owner, or awaiting confirmation (scope:vaults,engines,systems,privileged_only, orcadence)GET /api/v1/compliance/periods- Ended reporting periods and the snapshot closing eachGET/POST /api/v1/compliance/snapshots- Frozen snapshots: ad hoc (label,scope) or closing a period (cadence,period;supersedesto correct one);GET .../snapshots/{id}.jsonreturns the hashed bytes,.csvthe rowsGET /api/v1/compliance/settings- Reporting and confirmation settings fromconfig.yamlGET /api/v1/compliance/policies- Configured policies with their YAMLGET /api/v1/compliance/policies/{id}/evaluation- A policy evaluated now: status per control, failing secrets with their owners, and the trend across snapshotsGET /api/v1/compliance/snapshots/{id}/report.pdf- The snapshot's PDF/A report: summary, per-control results, failing secrets with owners, methodology; rendered from the snapshot alone
Snapshot exports list secret paths and owners; keep them as confidential as the instance itself.
GET /api/v1/audit/events- Change history, newest first (filters:vault_name,engine_id,secret_path,event_type,subject,since,until; paging withbefore_id)GET /api/v1/audit/events.jsonl- Export of the whole log as stored, for archiving or independent checksGET /api/v1/audit/verify- Recompute the hash chain; returns the head hash to note downPOST /api/v1/audit/events/{id}/revert- Undo a system or owner assignment change (recorded as a new event); backend data, severity and TTL can't be reverted from ChronowardenGET /api/v1/secrets/{id}/history- Changes to one secret
GET /api/v1/vault/instances- List configured Vault instances with connection status
GET /api/v1/health- Health check endpoint (used by Kubernetes probes)GET /api/v1/ready- Readiness check endpointGET /api/v1/info- API name and versionGET /api/v1/metrics- Prometheus metrics, whenmetrics.enabled: trueis set (otherwise404)
| Status | Description | Condition |
|---|---|---|
| 🟢 OK | Secret is healthy | days_remaining > alert_threshold |
| 🟡 Warning | Rotation needed soon | 0 < days_remaining ≤ alert_threshold |
| 🔴 Expired | Rotation overdue | days_remaining ≤ 0 |
| ⚪ No TTL | No rotation configured | chronowarden_ttl not set |
Where rotations must be confirmed (confirmation_required, per vault, engine or secret), a new version is also
shown as awaiting confirmation until someone confirms it was applied, and as overdue after confirmation_grace.
alert_threshold is set per expiry profile in config.yaml (default 30d); see
config.example.yaml.
Unit tests:
uv run pytestWith coverage:
uv run pytest --cov=chronowarden --cov-report=htmlTest compatibility with both HashiCorp Vault and OpenBao:
OpenBao (port 8200):
docker run -p 127.0.0.1:8200:8200 --name openbao-dev --detach quay.io/openbao/openbaoHashiCorp Vault (port 8201):
docker run -p 127.0.0.1:8201:8201 --cap-add=IPC_LOCK \
-e 'VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8201' \
-d --name=vault-dev hashicorp/vaultChronowarden maintains compatibility with both platforms as OpenBao intends to remain API compatible.
From 0.7.0 on, every release is built by GitHub Actions from a tag and comes with SLSA Build L3 provenance and a cosign keyless signature. Verify an image by its digest:
DIGEST=$(docker buildx imagetools inspect ghcr.io/fajfer/chronowarden:v0.7.0 --format '{{json .Manifest.Digest}}' | tr -d '"')
# Built by this repository's workflow from the tag (SLSA provenance)
slsa-verifier verify-image "ghcr.io/fajfer/chronowarden@${DIGEST}" \
--source-uri github.com/fajfer/chronowarden --source-tag v0.7.0
# Signed by the release workflow
cosign verify "ghcr.io/fajfer/chronowarden@${DIGEST}" \
--certificate-identity-regexp '^https://github.com/fajfer/chronowarden/\.github/workflows/docker\.yaml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comEach release also carries SBOMs in CycloneDX 1.6 and SPDX 2.3
JSON: one per image platform (chronowarden-image-{amd64,arm64}.*) and one for the source
(chronowarden-source.*, from uv.lock and frontend/package-lock.json). The image SBOMs are also attested to the
image:
cosign verify-attestation "ghcr.io/fajfer/chronowarden@${DIGEST}" --type cyclonedx \
--certificate-identity-regexp '^https://github.com/fajfer/chronowarden/\.github/workflows/docker\.yaml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe wheel, sdist and source SBOMs on the GitHub release are covered by
chronowarden.intoto.jsonl:
slsa-verifier verify-artifact chronowarden-0.7.0-py3-none-any.whl \
--provenance-path chronowarden.intoto.jsonl --source-uri github.com/fajfer/chronowarden --source-tag v0.7.0See deploy/ for:
- Docker Compose setup (
deploy/compose/) - Kubernetes manifests (
deploy/kubernetes/)
Milestones are tracked on GitHub. Design decisions are recorded
as ADRs in .ai/adr/.
| Milestone | Goal | Issues | Decisions |
|---|---|---|---|
| 0.6 Foundations | Clean core: config-only severity, no pre-1.0 shims, validated config, reliable sync, better logs, per-profile alert threshold | #73, #30, #12, #59, #58, #70, #19, #57, #31 | n/a |
| 0.7 Compliance evidence | Systems and owners, hash-chained audit log with change history and revert, rotation confirmation, privileged flag, frozen snapshots and reporting periods, removed secrets, DB migrations, optional metrics, signed releases with SLSA provenance and SBOMs, fuzzing, dev-setup rework, SvelteKit 3 | #61, #24, #76, #78, #79, #86, #87-#96 | ADR-010, ADR-014, ADR-017, ADR-018, ADR-021, ADR-023 |
| 0.7.x Policies and reports | Compliance policies in config (example YAML for KNF KRI ICT, PCI DSS 4.0, DORA in the docs), PDF reports with Typst | #97, #98 | ADR-019, ADR-020 |
| 0.8 Alerting | Built-in webhook alerting to owners, scheduled and headless sync, grace-period escalation | #48, #71, #74, #99 | ADR-022, ADR-013 |
| 0.9 Access control | Authentication for UI and API (only owners edit their secrets), then RBAC | n/a | ADR-015 |
| 1.0 | Stability promise for config, API, metrics and DB schema | n/a | ADR-016 |
| 1.x Backends | Generic provider registry, then public cloud vaults | #72 | ADR-011 |
Licensed under the EUPL-1.2 - see LICENSES/ for full text.
Our logo and mascot (currently unnamed), designed by Aleksandra Lejman, wears a vintage, polish banking guard uniform to symbolize security and honor the banking sector where the idea for this software was born.
The choice of a goat for the mascot was inspired by the author's daughter, Irena, who is a huge fan of the classic Koziołek Matołek series
Community support is available through Matrix channel as well as issues on GitHub
For commercial support, consultations and training feel free to reach me via email at damian (at) fajfer.org to discuss your needs and get a custom quote.
uv run mkdocs serve

