CrowdSec Processor — Design Document

1. Overview

Purpose: Fetch honeypot IP sighting data from CrowdSec for CVEs in our database, in two phases per run. Phase 1 ingests the two public (unauthenticated) CrowdSec free feeds. Phase 2 loops through CVEs in priority order against the authenticated CTI API until 50 successful responses (daily quota used productively), 3 consecutive HTTP 429s (quota exhausted), or all CVEs have been checked. Processes records matching allowedPrefixes (default: CVE-; configured via allowedPrefixes var in main.go).

Data sources:

  • Public free feeds (no auth, MISP Event JSON): https://feeds.crowdsec.net/free/1e4f328d-3db6-550f-93d3-3539adc8493f.json and https://feeds.crowdsec.net/free/2be9a716-39b8-5c18-bc9e-4ba7aefd8831.json
  • CrowdSec CTI API — https://cti.api.crowdsec.net/v2/smoke/search?query=cves:"<CVE-ID>"&since=30d

Schedule: Daily at 20:30 UTC (cron(30 20 * * ? *) — Terraform EventBridge)

Timeout: 60 minutes

Resources: 256 CPU units, 512 MB memory

What it reads:

  • Write pool (primary): CVEMetadata, Kev, ExploitCVE, CrowdSecLog for priority selection (write pool used to avoid read-replica replication lag)
  • Read pool: CVEMetadata batch lookup of (cveId → source) for feed sightings (the CrowdSecSighting composite FK requires the real source)
  • CrowdSec public free feeds (no auth) and the CrowdSec CTI API (authenticated with x-api-key header)

What it writes:

  • CrowdSecLog — Phase 1: one row per public-feed attribute (keyed on the attribute uuid). Phase 2: one row per CVE attempted, even on 403/429.
  • CrowdSecSighting — Phase 1: one row per (feed attribute × CVE), UUID v5 so re-runs are idempotent. Phase 2: one row per IP in the response items[], on 200 only.
  • S3 archive / quarantine (see § S3 Persistence)

No CVEMetadata, CVEAlias, Kev or Exploit rows are written — this processor is sighting-only and never mints or mutates advisory records.

Environment variables:

  • DATABASE_URL — required
  • DATABASE_URL_READ — optional
  • CROWDSEC_APIKEY — CrowdSec CTI API key (required)

2. Business Logic

Phase 1 — Public free feeds (no API key)

Before any authenticated call, both public feed URLs are fetched and parsed as MISP Event JSON. Each Event.Attribute entry is an IP whose comment field carries a CVEs: [...] list and whose Tag[] entries carry reputation / confidence / first-seen / last-seen. For every attribute with at least one CVE:

  • one CrowdSecLog row keyed on the attribute’s own uuid (ON CONFLICT DO NOTHING, httpStatus = 200, cveId = comma-joined CVE list, url = the feed URL);
  • one CrowdSecSighting per CVE, with a deterministic UUID v5 derived from logUuid + ":" + cveId so re-runs are idempotent.

CrowdSecSighting has a composite FK (cveId, source) → CVEMetadata, so all CVE ids in the feed are batch-resolved to their real source in one query up front and any CVE not yet in CVEMetadata is skipped rather than raising an FK violation. Feed failures are non-fatal: the phase logs and continues to the next feed, then to Phase 2.

Rate Limit & Loop (Phase 2)

CrowdSec CTI API: ~50 calls/day on the tier used. The processor loops through CVEs in each run until one of these stop conditions:

  • 50 successful (HTTP 200) responses in one run → daily quota used productively → exit 0
  • 3 consecutive HTTP 429 responses → quota exhausted → exit 0
  • No CVEs found → all CVEs checked → exit 0

On HTTP 429: insert a CrowdSecLog entry (so that CVE is skipped by priorities 1–5 on the next iteration), sleep 60 seconds, then continue the loop. The next findNextCVE call may select a different CVE. If quota is truly exhausted, subsequent calls will also 429, hitting the 3-consecutive limit.

On HTTP 403 or other non-200/429: log it and continue to the next CVE.

A rate-limited attempt permanently retires the CVE from the new-CVE queue. Priorities 1–5 all filter on NOT EXISTS (SELECT 1 FROM "CrowdSecLog" WHERE cveId = …), and the CrowdSecLog insert happens before the status is examined (main.go:207-215), so a 429 or 403 counts as “checked” for ever. The CVE can then only be revisited through priority 6, a round-robin over every logged CVE at ≤50 successful calls per day.

Production state as of this audit: 220,254 CrowdSecLog rows across 111,640 distinct CVEs, of which 132,284 are HTTP 429 and 4,827 are HTTP 403 — i.e. ~62% of all recorded attempts carry no data, and the priority-6 cycle over 111,640 CVEs at 50/day is roughly a six-year round trip. A KEV CVE whose first attempt was throttled loses its priority-1 standing permanently.

Fix: either exclude non-200 log rows from the priority filters (AND cl."httpStatus" = 200), or do not insert a CrowdSecLog row for 429 at all and rely on the in-run consecutiveTooMany counter to stop the loop. The intent of the current behaviour — “don’t retry the same CVE twice in one run” — needs an in-run set, not a permanent table row.

CVE Selection Priority

Only IDs matching allowedPrefixes (default: CVE-) are processed. All 6 priority queries apply a configurable prefix filter via LIKE $N (single prefix) or LIKE ANY($N::text[]) (multiple prefixes), driven by prefixCondition(). Each priority is checked in sequence; the first result found is processed. After inserting a CrowdSecLog for a CVE (regardless of HTTP status), that CVE is excluded from priorities 1–5 on subsequent iterations within the same run (because NOT EXISTS on CrowdSecLog excludes it). Priority 6 uses the write pool to see freshly inserted log rows.

Priority 1 — KEV CVEs not yet in CrowdSecLog CVEs from the Known Exploited Vulnerabilities (KEV/CISA) catalogue that have never been queried.

Priority 2 — Exploit-linked CVEs not yet in CrowdSecLog CVEs referenced by any ExploitCVE record that have never been queried.

Priority 3 — CVEMetadata published in last 30 days, not in CrowdSecLog, oldest first Most recently published CVEs — oldest within window first so the run catches up progressively.

Priority 4 — CVEMetadata published 30–90 days ago, not in CrowdSecLog, oldest first Mid-age CVEs not yet queried.

Priority 5 — All remaining CVEMetadata not in CrowdSecLog, newest first All other CVEs ordered by most recent — ensures new CVEs are seen before very old ones.

Priority 6 — Recheck oldest DISTINCT CrowdSecLog record When all CVEs have at least one log entry, rechecks the CVE whose last query is most stale (MIN of MAX(createdAt) across all log entries per CVE). Inserting a new log entry pushes this CVE to the back of the refresh queue naturally.

Write Pool for CVE Selection

All priority queries use pool.Write (the RDS primary) to avoid read-replica replication lag. After inserting a CrowdSecLog row, the same run’s next findNextCVE call immediately sees the new row — preventing the same CVE from being selected twice in one run.

CrowdSecLog — Always Written

Even on API failure, a CrowdSecLog record is inserted with:

  • httpStatus — actual HTTP status (or 0 on network error)
  • errorMessage — error string (null on success)
  • totalItems — count from items[] array (0 on error)
  • r2Path — a path string (/crowdsec/<date>/<uuid>.jsonc). ⚠ Nothing is ever written there. The raw body is archived by s3client.Uploader at crowdsec/files/{sha256}/{cveId}.json (see § S3 Persistence), so the r2Path column records a location that does not exist and cannot be used to retrieve the payload.

Sighting Insertion

For 200 responses: iterates items[] array. Each item with a non-empty ip field is inserted as CrowdSecSighting. Uses ON CONFLICT DO NOTHING.

Attack Details Filtering

The filterAttackDetails function removes entries from attack_details that reference other CVE IDs. Looks at name, label, description fields for CVE- prefix patterns; if a different CVE ID is found, excludes that entry. This keeps sightings relevant to the target CVE only.

CSV Fields

Several fields are stored as CSV strings (not arrays):

  • behaviorsCsvbehaviors[].name joined with ,
  • attackDetailsCsv — filtered attack_details[].name
  • classificationsCsvclassifications.classifications[].name
  • mitreTechniquesCsvmitre_techniques[].name

3. Architecture Diagram

graph TD subgraph "cmd/crowdsec-processor/" MAIN[main.go
loop until 50 successes, 3×429, or empty] FIND_CVE[findNextCVE
6-priority selection via write pool] FETCH[fetchCrowdSec
HTTP GET + x-api-key] SIGHTINGS[processSightings
insert per IP] FILTER[filterAttackDetails
strip other-CVE refs] HELPERS[strPtr, csvOf, nestedStr, etc.] end subgraph "internal/db/" POOL[pool.go — Pool] end MAIN -->|loop| FIND_CVE MAIN -->|per CVE| FETCH MAIN -->|200 only| SIGHTINGS MAIN -->|3×429 or 50 successes| DONE([Exit 0]) SIGHTINGS --> FILTER SIGHTINGS --> HELPERS MAIN --> POOL FIND_CVE -->|write pool: 6 priority queries| WRITE[pool.Write] MAIN -->|CrowdSecLog insert| WRITE SIGHTINGS -->|CrowdSecSighting insert| WRITE

4. Deployment Diagram

flowchart TD GHA[GitHub Actions
go-ecr-deploy.yml] -->|push ARM64 image| ECR[ECR: vdb-manager
tag: crowdsec-processor-sha-xxx] ECR --> TASKDEF[ECS Task Definition
go-processor-crowdsec-processor] TASKDEF --> EB[EventBridge Schedule
go-crowdsec-json-processor
cron 30 20 * * ? *] EB -->|trigger| FARGATE[ECS Fargate Task
vdb-scheduler cluster
ARM64 ap-southeast-2] FARGATE --> CW[CloudWatch Logs
/ecs/vdb-scheduler/go-crowdsec-json-processor] FARGATE -->|GET with x-api-key| CS_API[cti.api.crowdsec.net
/v2/smoke/search] FARGATE --> WRITE[RDS Write Proxy
CrowdSecLog + CrowdSecSighting] FARGATE -.->|CROWDSEC_APIKEY| SECRETS[AWS Secrets Manager]

5. Processing Flow

flowchart TD START([Start]) --> ENV{DATABASE_URL +
CROWDSEC_APIKEY set?} ENV -->|no| FAIL([Exit 1]) ENV -->|yes| CONNECT[Connect to DB pool] CONNECT --> LOOP_START[findNextCVE via write pool] LOOP_START --> P1{Priority 1:
KEV not in log?} P1 -->|yes| GOT_CVE[cveID selected] P1 -->|no| P2{Priority 2:
Exploit not in log?} P2 -->|yes| GOT_CVE P2 -->|no| P3{Priority 3:
last 30d not in log?} P3 -->|yes| GOT_CVE P3 -->|no| P4{Priority 4:
30-90d not in log?} P4 -->|yes| GOT_CVE P4 -->|no| P5{Priority 5:
any CVE not in log?} P5 -->|yes| GOT_CVE P5 -->|no| P6{Priority 6:
recheck oldest log?} P6 -->|yes| GOT_CVE P6 -->|no| EMPTY[No CVEs found] EMPTY --> EXIT0([Exit 0 — nothing to do]) GOT_CVE --> API[fetchCrowdSec
GET cti.api.crowdsec.net] API --> LOG[INSERT CrowdSecLog
uuid, httpStatus, totalItems, ...] LOG --> IS429{httpStatus == 429?} IS429 -->|yes| INC429[consecutiveTooMany++] INC429 --> GE3{>= 3?} GE3 -->|yes| EXIT0B([Exit 0 — quota exhausted]) GE3 -->|no| SLEEP[sleep 60s] SLEEP --> LOOP_START IS429 -->|no| RESET429[consecutiveTooMany = 0] RESET429 --> IS200{httpStatus == 200?} IS200 -->|yes| ITEMS[processSightings
for each item in items] IS200 -->|no 403/other| LOOP_START ITEMS --> IP_CHECK{item.ip non-empty?} IP_CHECK -->|no| SKIP[skip item] IP_CHECK -->|yes| CVE_CHECK{item cves contains
target CVE?} CVE_CHECK -->|no, cves non-empty| SKIP CVE_CHECK -->|yes or cves empty| FILTER[filterAttackDetails
remove other-CVE entries] FILTER --> INSERT_SIGHT[INSERT CrowdSecSighting
ON CONFLICT DO NOTHING] INSERT_SIGHT --> ITEMS SKIP --> ITEMS ITEMS -->|done| INC_OK[processedTotal++] INC_OK --> GE50{>= 50?} GE50 -->|yes| EXIT0C([Exit 0 — 50 successes]) GE50 -->|no| LOOP_START

6. Data Mapping

erDiagram Kev { string cveID PK string source PK } ExploitCVE { string cveId string source } CVEMetadata { string cveId PK string source PK int datePublished "Unix seconds" } CrowdSecLog { string uuid PK string r2Path "future S3 path" string url "full API URL" string cveId "CVE- prefixed only" int httpStatus "200/403/429/0" string errorMessage int totalItems bigint createdAt "used for priority-6 ordering" } CrowdSecSighting { string uuid PK string crowdSecLogUuid FK string cveId string source string ip string reputation string confidence int backgroundNoiseScore string asName int asNum string ipRange24 string locationCountry string locationCity float locationLat float locationLon string reverseDns string behaviorsCsv "CSV of behavior names" string attackDetailsCsv "CSV — filtered to target CVE" string classificationsCsv string mitreTechniquesCsv bigint firstSeen bigint lastSeen int falsePositivesCount int scoreLastDayAggressiveness int scoreLastDayThreat int scoreLastDayTrust bigint createdAt bigint updatedAt } Kev ||--o{ CVEMetadata : "priority 1 join" ExploitCVE ||--o{ CVEMetadata : "priority 2 join" CVEMetadata ||--o{ CrowdSecLog : "queried for" CrowdSecLog ||--o{ CrowdSecSighting : "produces"

S3 Persistence

  • Archive path: crowdsec/files/{sha256}/{filename}
  • Quarantine path: failed-feeds/crowdsec-processor/{YYYY-MM-DD}/{reason}/{filename}
  • Failure reasons emitted: parse-error, fetch-error

Uses s3client.Uploader from internal/s3client/uploader.go. Skipped when S3_BUCKET_NAME is unset (local dev).

flowchart LR SRC[Source feed] --> PROC[crowdsec-processor] PROC -->|success| ARCHIVE[("S3: crowdsec/files/{sha256}/{filename}")] PROC -->|failure| Q[("S3: failed-feeds/crowdsec-processor/{date}/{reason}/{filename}")] PROC --> DB[(PostgreSQL)]

See the S3 Persistence Contract for the full reason taxonomy.