CERT-UA RSS Processor — Design
Overview
Fetches APT campaign reports and vulnerability advisories from the Ukrainian national CERT (CERT-UA / cert.gov.ua) RSS feed. Advisory text is narrative Ukrainian prose; CVE IDs, CVSS vectors, and reference URLs are extracted by regex from the plain-text description.
One CVEMetadata record is created per CVE ID found per feed item.
Items with no CVE IDs are skipped entirely — CERT-UA publishes many
threat-intelligence reports that track threat actors (UAC-NNNN / APT28 etc.)
without referencing specific CVEs.
Feed
| Property | Value |
|---|---|
| URL | https://cert.gov.ua/api/articles/rss |
| Auth | None — fully public |
| Format | RSS 2.0, UTF-8 |
| Language | Ukrainian (uk) |
| Items | ~10 most recent advisories |
Each item: <title>, <link>, <guid>, <description>, <pubDate>.
<guid> and <link> are typically identical (article URL).
Content
CERT-UA advisories are threat-intelligence campaign reports, not structured CVE bulletins. A typical item describes:
- Threat actor designation (e.g. UAC-0001 / APT28, UAC-0255)
- Attack date range and targeted organisations
- Delivery mechanism (phishing email, malicious attachment, supply chain)
- Malware names (all-caps: AGEWHEEZE, SHADOWSNIFF, CABINETRAT, …)
- Occasionally one or more CVE IDs embedded in the Ukrainian text
CVE IDs appear in running prose, e.g.:
“…що містив експлойт CVE-2026-21509 та був присвячений…”
CVSS vectors and embedded reference URLs are uncommon but handled.
Extraction
All extraction is regex-based against the plain-text-stripped description.
| Field | Regex / method |
|---|---|
| CVE IDs | CVE-\d{4}-\d{4,} — deduplicated, uppercase, order-preserving |
| CVSS v3.x vectors | Full metric-enumerated pattern anchored at CVSS:3.[01]/AV:… |
| CVSSv2 vectors | \bAV:[LNA]/AC:[LMH]/Au:[MSN]/C:[NPC]/I:[NPC]/A:[NPC]\b |
| Reference URLs | https?://[^\s<>"'()\x00-\x1f]+ — item link excluded |
| HTML stripping | <br> / </p> → newline, strip all remaining tags |
| HTML entities | html.UnescapeString (double-pass if still encoded) |
The CVSS v3 regex uses enumerated valid values per metric (not [A-Z]) to
prevent false positives in Ukrainian narrative text.
Multiple CVEs per item
A single feed item may mention multiple CVE IDs. One CVEMetadata record is
created per CVE. All records from the same item share the same description,
references, sourceAdvisoryRef (GUID), and sourceFileHash.
The mapper passes the sibling CVE IDs as Aliases
(internal/certua/mapper.go:33-38), but no CVE↔CVE CVEAlias edge is created:
a campaign report naming several CVEs is a bundle, and db.InsertAliases drops
CVE-prefixed aliases whenever the list holds more than one
(internal/db/cvealias.go:65-88). The CVEAlias rows this processor does
produce are the same-cveId cross-source edges — (CVE-x, cert-ua) linked to
every other source carrying CVE-x, which is what makes a CERT-UA in-the-wild
observation reachable from the NVD/cve.org record for the same CVE.
Parsing
| Advisory field | RSS source |
|---|---|
AdvisoryID | Last path segment of <link> (e.g. “6287250”) |
GUID | <guid> — primary dedup key |
URL | <link> |
Title | <title> |
Description | Plain-text stripped <description> |
CVEIDs | Regex on description |
CVSSv3Vectors | Regex on description |
CVSSv2Vectors | Regex on description |
ReferenceURLs | URL regex on description (item link excluded) |
PubDateUnix | Tries RFC1123Z → RFC1123 → RFC3339 → 2006-01-02 15:04:05 → 2006-01-02 |
ContentHash | SHA1(GUID | rawDescription) |
Storage
No new tables or columns required.
| Table | Rows inserted |
|---|---|
CVEMetadata | One per CVE ID; source="cert-ua" |
CVEDescription | One per CVE; lang="uk" (Ukrainian) |
CVEMetadataReferences | Article URL (type=advisory) + extracted URLs |
CVEAlias | Same-cveId cross-source edges only (co-listed CVE↔CVE aliases are suppressed — see “Multiple CVEs per item”) |
CVEMetric | One per distinct CVSS vector (cvssV3_1, cvssV3_0, cvssV2_0) |
No Affected or ProblemType rows — feed has no CPE or CWE data.
Incremental Strategy
Content-hash dedup via sourceAdvisoryRef + sourceFileHash in CVEMetadata.
sourceAdvisoryRef= item GUID (article URL)sourceFileHash= SHA1(GUID + “|” + rawDescription)
An item is skipped if processedHashes[guid] == contentHash. Using the raw
description hash (not URL-only) means that if CERT-UA later edits an article
to add more CVEs, the changed hash causes the item to be reprocessed and new
records created.
Error Alerting
Uses the shared internal/notify package. The full alerting chain is:
processor (Go) → SNS topic (vdb-processor-events)
→ Lambda (vdb-notify-dispatcher)
→ Slack (chat.postMessage) or Google Chat (cardsV2 incoming webhook)
depending on NOTIFY_BACKEND (default googlechat)
Events and Slack behaviour:
| Event | Slack alert |
|---|---|
task.started | No |
task.completed | No |
task.no_work | No |
task.errored | Yes — shows error text + stats + CloudWatch/ECS deep links |
task.overtime | Yes — shows expected vs elapsed duration |
Per-item failure path (RecordError → Errored):
When a database transaction fails for a specific CVE (e.g. constraint error,
connection reset), notifier.RecordError("store CVE-XXXX-YYYY: <error>") is
called. At the end of the run, notifier.HasErrors() is checked:
if notifier.HasErrors() {
notifier.Errored("cert-ua-rss-processor", stats, nil)
} else {
notifier.Completed("cert-ua-rss-processor", stats)
}
This ensures that partial failures (where some CVEs stored successfully but others did not) still trigger a Slack alert with the accumulated error detail.
Fatal failure path (feed fetch / parse fail → Errored → os.Exit(1)):
If the RSS feed cannot be fetched or parsed, notifier.Errored is called
immediately and the process exits non-zero. ECS records the exit code.
Overtime (task.overtime): if the task runs beyond
EXPECTED_DURATION_MINUTES (30 min), the notify package publishes
task.overtime and cancels the context, stopping the processing loop.
Flags
| Flag | Default | Description |
|---|---|---|
--all | false | Reprocess all advisories regardless of hash |
--limit | 0 | Maximum advisories to process (0 = unlimited) |
--force | false | Force reprocessing even if content hash unchanged |
ECS Schedule
Daily at 08:00 UTC (cron(0 8 * * ? *)).
Resources: 256 CPU units, 512 MB memory, expected_duration_minutes = 30.
Environment variables supplied via go_task_environment Terraform local:
DATABASE_HOST, S3_BUCKET_NAME, SNS_TOPIC_ARN.
Secrets supplied via go_task_secrets Terraform local:
DB_USERNAME, DB_PASSWORD.
Key Files
| File | Purpose |
|---|---|
cmd/cert-ua-rss-processor/main.go | Main processor binary |
internal/certua/types.go | RSS XML types and Advisory struct |
internal/certua/parser.go | Feed parsing, text extraction, dedup hash |
internal/certua/parser_test.go | Unit tests for all extractors |
internal/certua/mapper.go | Advisory → []CVESourceData mapping |
internal/notify/notify.go | SNS lifecycle notifications (shared) |
lambda/notify-dispatcher/main.go | SNS → Slack / Google Chat dispatcher Lambda |
terraform/notifications.tf | SNS topic, Lambda, notify secret, subscription |
terraform/go-schedules.tf | ECS task definition + EventBridge schedule |
S3 Persistence
- Archive path:
cert-ua/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/cert-ua-rss-processor/{YYYY-MM-DD}/{reason}/{filename}✓ - Failure reasons emitted:
parse-error,store-error
Uses s3client.Uploader from internal/s3client/uploader.go. Skipped when S3_BUCKET_NAME is unset (local dev).
See the S3 Persistence Contract for the full reason taxonomy.