Get CVE alerts for the products you run.
Polls NVD on a schedule. Matches CVEs against your product list by CPE. Alerts Google Chat with severity, KEV status, EPSS score, and a curated title. Never alerts twice for the same CVE-state.
- Repo:
ccc· Brand:c³ - Python 3.11+, 4 hash-pinned dependencies (multi-Python ABI in
requirements.lock) - Runs in Docker (recommended) or local venv via
./setup.sh --local - Six flat state files, no database (see State files)
- Dedup proven by 10 unit tests in
tests/test_dedup.py - ~2500 LOC across 12 modules
git clone https://github.com/rvzsec/ccc.git && cd ccc
./setup.sh # detects docker, builds image, resolves CPEs
$EDITOR config/config.yaml # paste your Google Chat webhook URLThat's it. To run:
docker compose run --rm ccc runTo schedule it hourly via systemd, see Scheduling below.
./setup.sh auto-detects docker (recommended). Pass --local if you want a pip install into a venv instead of docker (still hash-pinned).
Single Google Chat card per CVE. Severity word colored inline. KEV value colored red when active.
CVE-2021-44228: Apache Log4j2 Remote Code Execution Vulnerability
Product: Apache Log4j
CVSS: 10.0 Critical / CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Status: Modified
EPSS: 97.6%
KEV: Yes (CISA actively exploited)
Advisory: https://nvd.nist.gov/vuln/detail/CVE-2021-44228
Title resolution (zero extra HTTP calls):
- CISA KEV
vulnerabilityNameif the CVE is on the KEV list - CWE name + product if NVD assigned a known CWE (e.g.
Deserialization of Untrusted Data in Apache Tomcat) - First sentence of the NVD description as fallback
KEV-curated names are cached locally for 6 h. CWEs come from the NVD response itself, so no extra request.
See config/config.example.yaml. Knobs you care about:
| Field | Default | Effect |
|---|---|---|
nvd_api_key |
"" |
NVD key, 50 req/30 s with, 5 req/30 s without |
severity_floor |
high |
Drop CVSS below this. KEV bypasses unless disabled. |
kev_bypass_floor |
true |
KEV CVEs alert regardless of severity floor |
epss_threshold |
0.5 |
EPSS value that triggers the priority highlight (display only) |
alert_on_update |
false |
Re-alert when an already-seen CVE's hash changes |
poll_overlap_minutes |
10 |
Window overlap to absorb NVD eventual consistency |
max_lookback_hours |
24 |
Cap on lookback when state is stale or missing |
google_chat_webhook |
"" |
Incoming Webhook URL from a Chat space |
state_dir |
/state |
Container path for state files |
products_file |
/config/products.yaml |
Container path for products list |
Two forms — plain names (auto-resolved to CPE) or dict overrides (explicit CPE). Mix freely.
Plain names (recommended). ccc resolves each name to the canonical CPE via
NVD's CPE Dictionary API on first run and caches it in state/cpe_cache.json
forever. No CPE syntax to learn.
products:
- jenkins
- apache log4j
- fortinet fortios
- linux kernel
- openssl
- nginxDict overrides. Use when (a) the vendor has multiple product lines under
the same name (e.g. vmware vcenter ambiguously matches vcenter_server vs
vcenter_operations vs vcenter_orchestrator), (b) NVD has no CPE for the name
you used, or (c) you want a strict version pin. Find CPEs at
https://nvd.nist.gov/products/cpe/search.
products:
- jenkins # plain
- name: "VMware vCenter Server"
cpe: "cpe:2.3:a:vmware:vcenter_server:*" # override
- name: "Apache Log4j 2.14.1 only"
cpe: "cpe:2.3:a:apache:log4j:2.14.1:*:*:*:*:*:*:*" # version pinMatches are by (part, vendor, product) triple, case-insensitive. Wildcard
versions match anything; pinned versions require exact CPE prefix.
First-run latency: ccc waits 6 s between NVD CPE lookups (NVD's
recommended pacing). 10 plain names cold-cache = ~60 s. With an nvd_api_key
it's ~12 s. Subsequent runs are instant (cache hits).
Run docker compose run --rm ccc validate (or .venv/bin/ccc validate in
local mode) to resolve everything and verify before scheduling.
products.yaml supports an optional categories: block that groups products
by alert policy — e.g. tier-1 production apps get immediate cards, consumer
apps get one weekly digest per tier instead of hourly spam.
categories:
default: # implicit if you omit the block
alert_mode: individual
consumer:
alert_mode: batch
batch_period: weekly # daily | weekly
accent_color: "#d4ac0d" # optional colored footer line
products:
- jenkins # -> default (individual)
- name: "VLC media player"
cpe: "cpe:2.3:a:videolan:vlc_media_player:*"
category: consumer # -> weekly digestalert_mode: individual— one card per CVE, sent immediately.alert_mode: batch— CVEs accumulate instate/pending_batches.jsonper tier and flush as ONE digest card per tier when the period elapses (daily or weekly), grouped by product inside the card:Product A (2)→ its CVEs, thenProduct B (1)→ its CVE. Products with no category share the implicitdefaulttier, so untiered batch products still produce a single card. A failed flush retries next run; flushed CVEs are recorded inrecent.jsonso they never re-alert.accent_color: "#rrggbb"— renders a footer on every card in the category (both individual cards and digests): the tier label in italic + a colored line, both in the accent color. Untiered (default) products get the line only. Example tier mapping: tier1#cc0000, tier2#e67e22, tier3#d4ac0d, tier4#2e86c1, tier5#7f8c8d.label: "Tier 1 - Production Stack"— optional pretty name shown on the card footer instead of the raw category key (tier1). Falls back to the key when omitted.- A per-product
alert_mode:key overrides the category for that product. - The digest card opens with a generic
C³ - New CVE Alertsheader, then the summary (CVSS range, severity, EPSS, KEV) and the product-grouped, numbered, hyperlinked advisory list. The tier label + colored line sit in the footer.
NVD keeps re-touching ancient CVEs — analysts edit entries, lastModified
moves, and the CVE re-enters your poll window even though it was published
years ago. To only alert on CVEs published in or after a given year, set in
config/config.yaml:
min_cve_year: 2025 # null / omitted = no limitCVEs published before 2025 are dropped in find_matches before any matching
or queueing — they never reach the webhook.
In a Chat space: Apps & integrations → Webhooks → Add webhook. Use the Incoming Webhook URL, not an Apps Script URL.
| Layer | Defends against | Mechanism |
|---|---|---|
| L1 | Concurrent runs (cron overrun, manual re-run, systemd flap, double cron entry) | fcntl.flock(LOCK_EX | LOCK_NB) on state/ccc.lock. Second invocation exits 0 silently. |
| L2 | NVD republishing unchanged CVEs | Query lastModStartDate = last_run - overlap. Only CVEs NVD actually touched appear in the window. |
| L3 | The overlap window re-emitting the same CVE-state | recent.json stores sha256(cve_id + cvss + kev + epss_bucket + vuln_status). Skip if hash unchanged; [UPDATED] if changed (suppressed unless alert_on_update: true). |
Verified by tests/test_dedup.py:
- 24 hourly ticks → exactly the right alerts, zero dupes.
- Cron-overrun scenario (two ticks 30 min apart, same window) → second tick alerts zero.
- Stale state → window correctly capped at
max_lookback_hours.
A CVE alerts only if ALL true:
- It has a
vulnerable: trueCPE inproducts.yamlmatched by(part, vendor, product). vulnStatusis notRejected.- Either CVSS ≥
severity_floor, or CVE is on CISA KEV andkev_bypass_floor: true. - Hash differs from any prior entry in
recent.json. UPDATED variants are dropped unlessalert_on_update: true.
alert_on_update: false is the default. Most teams do not act on NVD revising
a CVE you already triaged. Flip to true if your workflow wants
[UPDATED] cards on material changes.
Under the ccc-state Docker volume (/state inside the container) — or
./state/ in local mode. All flat files, no database, bounded growth.
state/
├── ccc.lock # flock fd, 0 bytes - L1 dedup
├── last_run.txt # one ISO-8601 UTC timestamp - written on full success
├── recent.json # {cve_id: {hash, seen_at}}, rotates entries > 14 days
├── pending_batches.json # accumulated digest entries per batch-mode product
├── audit.jsonl # append-only log; rotates at 10 MB, keeps 5 backups
├── kev.json # CISA KEV cache, refreshed every 6 h (~130 KB)
└── cpe_cache.json # resolved product-name -> CPE lookup, written once per product
Total state size in typical use: ~200 KB. Hard cap: ~60 MB once
audit.jsonl reaches rotation. No file grows unbounded.
Delete any of them and the worst case is one bonus alert per CVE. No data loss, no rebuild needed.
Inspect with make state or make logs.
Four direct dependencies, all top-tier maintainers, all hash-pinned:
| Dep | Version | Maintainer |
|---|---|---|
httpx |
0.27.2 | Encode (Tom Christie, Starlette / FastAPI) |
pydantic |
2.9.2 | Pydantic team |
pyyaml |
6.0.2 | Ingy döt Net + Matt Davis |
click |
8.1.7 | Pallets (Armin Ronacher, Flask / Jinja2) |
Full dependency closure (direct + transitive) pinned in requirements.lock.
Do not trust the lockfile until you audit it. Run:
make auditThis pulls every pinned wheel from PyPI inside a clean container and compares
SHA256 against requirements.lock. Any mismatch aborts loud.
Other defenses:
pip install --require-hashesinside the build container. Tampered wheels fail install, not runtime.- Container runs as non-root (
uid=1000), read-only rootfs, all caps dropped,no-new-privileges. - State and config mounted at runtime. Nothing baked into the image.
yaml.safe_loadonly. Noyaml.load, no arbitrary Python object construction.- Image base:
python:3.13-slim-bookworm(Debian, minimal).
sudo cp systemd/ccc.service systemd/ccc.timer /etc/systemd/system/
sudo sed -i "s|/opt/ccc|$(pwd)|" /etc/systemd/system/ccc.service
sudo systemctl daemon-reload
sudo systemctl enable --now ccc.timer
sudo systemctl list-timers ccc.timerOnCalendar=hourly with RandomizedDelaySec=300 so deployments don't all hit
NVD at :00. Persistent=true catches up on missed runs after host reboot.
15 * * * * cd /opt/ccc && /usr/bin/docker compose run --rm ccc runEither way, the flock guard prevents overlap and the time math handles
missed runs up to max_lookback_hours.
The four commands you'll actually use:
docker compose run --rm ccc validate # check config + product CPEs
docker compose run --rm ccc test-webhook # send a test message to your Chat space
docker compose run --rm ccc run # one poll cycle (this is what cron calls)
docker compose run --rm ccc run --dry-run # poll + print alerts, send nothingIn local mode, replace docker compose run --rm ccc with .venv/bin/ccc.
| Code | Meaning |
|---|---|
0 |
Success, or another instance is running (silent) |
1 |
Config / usage error |
2 |
NVD API failure - last_run NOT advanced, next run retries the window |
3 |
Webhook failure - last_run NOT advanced, the failing CVE is rolled back so it re-fires |
- NVD assignment lag. A CVE may be reserved at MITRE for days before NVD enriches it with CPEs. Without CPEs the matcher cannot link it to your product. Most major CVEs (Apache, Fortinet, Cisco) get CPEs within hours; some take days. There is no fix at this layer.
AWAITING_ANALYSISstatus. NVD published the CVE but hasn't scored CVSS yet. The gate drops these UNLESS they are KEV-listed.CVSS: N/Ais noise; KEV-bypass guarantees you still catch actively-exploited ones immediately.- No CPE on the CVE. Some CVEs never get CPEs (junk submissions, hardware-only bugs). The matcher cannot see them. Keyword fallback is intentionally not implemented to keep signal clean.
ccc/
├── pyproject.toml # 4 hash-pinned deps + project metadata
├── requirements.lock # full transitive closure, multi-Python ABI hashes
├── README.md # this file
├── LICENSE # MIT (Zyenra Security)
├── setup.sh # auto-detect + install (docker mode or --local mode)
├── Dockerfile # 2-stage, --require-hashes, non-root uid=1000
├── docker-compose.yml # 3 services: ccc, test, audit
├── Makefile # contributor targets (build / test / audit / shell / clean)
├── config/
│ ├── config.example.yaml # documented knobs
│ └── products.example.yaml # plain names + dict-override forms
├── ccc/ # 10 modules, ~1800 LOC
│ ├── cli.py # click CLI: run, validate, test-webhook, version
│ ├── config.py # pydantic v2 models, CPE 2.3 validator
│ ├── lock.py # fcntl.flock guard - L1 dedup
│ ├── state.py # last_run.txt + recent.json + audit.jsonl rotation - L2/L3 dedup
│ ├── nvd.py # NVD 2.0 client, ISO-8601-ms, 120 d cap, 6 s pacing, 429 retry
│ ├── enrich.py # EPSS batch JSON + CISA KEV cached 6 h (with stale fallback)
│ ├── matcher.py # CPE triple matching, severity floor + KEV bypass
│ ├── notifier.py # Google Chat cardsV2, title resolver, colored severity
│ └── resolver.py # NVD CPE Dictionary lookup, on-disk cache, ambiguity handling
├── tests/
│ └── test_dedup.py # 10 tests: dedup, CPE case, HTML escape, dry-run, window math
├── systemd/
│ ├── ccc.service # Type=oneshot
│ └── ccc.timer # hourly + RandomizedDelaySec=300 + Persistent=true
└── scripts/
├── audit.sh # independent hash verifier
├── collect_hashes.sh # regenerate lockfile after version bump
└── sample_alert.py # send one Log4Shell sample card for format review
Contributor commands (not needed for normal use):
make audit # SHA256 every pinned dep against PyPI
make build # build the ccc:local image
make test # run the dedup test suite in container
make shell # interactive shell inside container
make state # dump state volume contents
make logs # tail audit.jsonl
make clean # remove image + state volume (DESTRUCTIVE)To run the test suite end-to-end:
make testTo regenerate the dependency lockfile after a version bump, see comments at the top of requirements.lock.
MIT. See LICENSE.