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

PropertyValue
URLhttps://cert.gov.ua/api/articles/rss
AuthNone — fully public
FormatRSS 2.0, UTF-8
LanguageUkrainian (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.

FieldRegex / method
CVE IDsCVE-\d{4}-\d{4,} — deduplicated, uppercase, order-preserving
CVSS v3.x vectorsFull 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 URLshttps?://[^\s<>"'()\x00-\x1f]+ — item link excluded
HTML stripping<br> / </p> → newline, strip all remaining tags
HTML entitieshtml.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 fieldRSS source
AdvisoryIDLast path segment of <link> (e.g. “6287250”)
GUID<guid> — primary dedup key
URL<link>
Title<title>
DescriptionPlain-text stripped <description>
CVEIDsRegex on description
CVSSv3VectorsRegex on description
CVSSv2VectorsRegex on description
ReferenceURLsURL regex on description (item link excluded)
PubDateUnixTries RFC1123Z → RFC1123 → RFC3339 → 2006-01-02 15:04:052006-01-02
ContentHashSHA1(GUID | rawDescription)

Storage

No new tables or columns required.

TableRows inserted
CVEMetadataOne per CVE ID; source="cert-ua"
CVEDescriptionOne per CVE; lang="uk" (Ukrainian)
CVEMetadataReferencesArticle URL (type=advisory) + extracted URLs
CVEAliasSame-cveId cross-source edges only (co-listed CVE↔CVE aliases are suppressed — see “Multiple CVEs per item”)
CVEMetricOne 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:

EventSlack alert
task.startedNo
task.completedNo
task.no_workNo
task.erroredYes — shows error text + stats + CloudWatch/ECS deep links
task.overtimeYes — shows expected vs elapsed duration

Per-item failure path (RecordErrorErrored):

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 → Erroredos.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

FlagDefaultDescription
--allfalseReprocess all advisories regardless of hash
--limit0Maximum advisories to process (0 = unlimited)
--forcefalseForce 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

FilePurpose
cmd/cert-ua-rss-processor/main.goMain processor binary
internal/certua/types.goRSS XML types and Advisory struct
internal/certua/parser.goFeed parsing, text extraction, dedup hash
internal/certua/parser_test.goUnit tests for all extractors
internal/certua/mapper.goAdvisory[]CVESourceData mapping
internal/notify/notify.goSNS lifecycle notifications (shared)
lambda/notify-dispatcher/main.goSNS → Slack / Google Chat dispatcher Lambda
terraform/notifications.tfSNS topic, Lambda, notify secret, subscription
terraform/go-schedules.tfECS 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).

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

See the S3 Persistence Contract for the full reason taxonomy.