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 - ~1800 LOC across 10 modules
git clone https://github.com/zynsec/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.
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
├── 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.