CERT.at RSS Processor — Design

Overview

Fetches security warnings from the Austrian national CERT (CERT.at) Atom 1.0 feed and creates CVEMetadata rows (source=cert-at) under original CVE IDs.

Advisories are in German with structured HTML content containing CVE IDs, CVSS scores, affected products, and reference URLs organized under standard section headings.

Feed

PropertyValue
URLhttps://www.cert.at/cert-at.de.warnings.atom_1.0.xml
AuthNone — fully public
FormatAtom 1.0 with Dublin Core extensions
LanguageGerman (de)
Items~18 security warnings

Parsing

The <summary type="html"> content is HTML-entity-encoded. After decoding, it contains structured sections under <h2> headings:

Section HeadingData Extracted
BeschreibungDescription text, CVE IDs (CVE-Nummer(n):), CVSS score (CVSS Base Score:)
AuswirkungenImpact text
Betroffene SystemeAffected products from <li> items
AbhilfeRemedy/mitigation text
Informationsquelle(n)Reference URLs from <a href> tags
FieldSource
Advisory URL<link rel="alternate" href="...">
Title<title> (German)
Published<published> or <dc:date> parsed as ISO 8601
Updated<updated> parsed as ISO 8601
CVE IDsRegex CVE-\d{4}-\d{4,} from Beschreibung
CVSS ScoreRegex CVSS Base Score: (bis zu )?\d+.\d+
Affected Products<li> elements in Betroffene Systeme
Reference URLs<a href> elements in Informationsquelle(n)
Content HashSHA1(id|title|updated)

ID Strategy

All items contain upstream CVE IDs — no VVD minting needed. Multi-CVE advisories create one CVEMetadata row per CVE with cross-references as aliases. Items without CVE IDs (e.g., phishing warnings) are skipped.

Storage

No new tables or columns. All tables already exist.

TableRows inserted
CVEMetadataOne per CVE ID; source="cert-at", cveId=CVE-YYYY-NNNN
CVEDescriptionOne per CVE; containerType="cna", lang="de"
CVEMetadataReferencesAdvisory URL (type="advisory") + vendor/technical refs
CVEMetricCVSS score when available (metricType="cvssV3_1")
CVEAffectedProducts from “Betroffene Systeme” section
CVEAliasCross-references between CVEs in the same advisory — only for two-CVE advisories (see below)

db.InsertAliases treats a CVE-prefixed primary id carrying two or more CVE-prefixed aliases as bundling and drops the CVE entries (internal/db/cvealias.go:65). A three-or-more-CVE CERT.at warning therefore produces no CVE↔CVE edges at all; a two-CVE warning does, because each record then carries exactly one CVE alias. Every row still gets same-cveId cross-source edges linking cert-at to every other source holding that CVE, so the record is never orphaned in the alias graph.

CVEMetadata.vectorString is NULL for every row — correct here, because the feed publishes a base score and no vector.

Incremental Strategy

On startup, load all sourceAdvisoryRef values from CVEMetadata where source='cert-at' into a map[string]bool. Per advisory: if the URL is in the set and --all/--force is false, skip. After successful processing, add the URL to the in-memory set.

Flags

FlagDefaultDescription
--allfalseReprocess all advisories, not just new ones
--limit0Maximum advisories to process (0 = unlimited)
--forcefalseForce reprocessing even if content unchanged

Error Alerting

SituationAction
Feed fetch failure (after 3 retries)notifier.Errored + os.Exit(1) — Slack
Feed parse failurequarantine + notifier.Errored + os.Exit(1) — Slack
Per-CVE transaction failurenotifier.RecordError, advisory quarantined, loop continues
End of runalways notifier.Completed

Because the run always ends in Completed, and notify.Completed publishes only Stats — it does not flush the messages accumulated by RecordError (internal/notify/notify.go:213) — per-advisory store failures never reach Slack. They are visible in the task logs and the EMF record-error metric only, and are not reflected in the reported stats either. Sibling processors (cert-it, cert-eu) use notifier.HasErrors() to choose Errored over Completed; this one does not.

ECS Schedule

Runs once daily (cron(0 6 * * ? *)), schedule_enabled = true, expected_duration_minutes = 30.

The soft deadline is EXPECTED_DURATION_MINUTES - 10, and the loop guard fires a further 5 minutes early — so an ECS run stops after 15 minutes. A local backfill recipe unsets EXPECTED_DURATION_MINUTES, which does not remove the deadline: the hard-coded 10-minute default applies, leaving a 5-minute effective budget.

Key Files

FilePurpose
cmd/cert-at-rss-processor/main.goMain processor
internal/certat/types.goAtom feed and advisory Go structs
internal/certat/parser.goFeed parsing, HTML section extraction, CVE/CVSS extraction
internal/certat/mapper.goAdvisory → CVESourceData mapping
schemas/cert_at_rss_advisory.schema.jsonJSON Schema Draft 7 for parsed advisory object

S3 Persistence

  • Archive path: cert-at/files/{sha256}/{filename}
  • Quarantine path: failed-feeds/cert-at-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-at-rss-processor] PROC -->|success| ARCHIVE[("S3: cert-at/files/{sha256}/{filename}")] PROC -->|failure| Q[("S3: failed-feeds/cert-at-rss-processor/{date}/{reason}/{filename}")] PROC --> DB[(PostgreSQL)]

See the S3 Persistence Contract for the full reason taxonomy.