ACSC Alert RSS Processor — Design
Overview
Fetches security alerts from the Australian Cyber Security Centre (ACSC) RSS
2.0 feed (https://www.cyber.gov.au/rss/alerts) and creates first-class
CVEMetadata rows (source=acsc) with minted ACSC-YYYY-N identifiers.
CVE IDs embedded in alert titles and descriptions are extracted and stored as
GcveAlias and CVEAlias records linking them to the minted identifier.
Feed
| Property | Value |
|---|---|
| URL | https://www.cyber.gov.au/rss/alerts |
| Auth | None — fully public |
| Format | RSS 2.0 with dc:creator namespace |
| Items | 2 as of 2026-08-06 (verified live). This is a rolling window of current “CRITICAL/HIGH ALERT” items, not a catalogue — ACSC drops older alerts out of it, so the 10 rows in production are the accumulation of what happened to be in the window at each daily run |
We consume the smallest of three ACSC channels. Verified live 2026-08-06:
/rss/alertsserves 2 items,/rss/advisoriesserves 3 (e.g. “Russian state-supported cyber actors … Zimbra Collaboration Suite”, “ClickFix distributing Vidar Stealer via WordPress targeting Australian infrastructure”), and/rss/newsserves 14. Only/rss/alertsis infeedURL(main.go:37), so the advisories channel — which is squarely vulnerability/threat content — is not ingested at all. Because both feeds are rolling windows and the poll is daily, anything that enters and leaves a window between two runs is lost permanently.
Parsing
| Field | Source |
|---|---|
| Advisory ID | Last path segment of <link> |
| Advisory URL | <link> (fallback: <guid>) |
| Title | <title> |
| Description | <description> |
| Creator | <dc:creator> |
| CVE IDs | All CVE-\d{4}-\d{4,} matches in title + description, deduped |
| Published | <pubDate> parsed as RFC 2822 → Unix seconds |
| Content Hash | SHA1(guid|pubDate|description) |
ID Generation
Each advisory is assigned a ACSC-{year}-{seq} identifier:
- On startup, query
MAX(sequenceNumber)fromGcveIssuancewheregcveId LIKE 'GCVE-110-ACSC-{year}-%' - For each new advisory, increment the sequence counter for the advisory’s publication year
- Store
ACSC-YYYY-NasCVEMetadata.cveIdandGCVE-110-ACSC-YYYY-NasGcveIssuance.gcveId - Link extracted CVE IDs and advisory slug as
GcveAliasrecords - Store CVE IDs in
CVEAliastable linking them to the ACSC identifier
Two hazards follow from minting an identifier per run rather than deriving one from the advisory:
--allduplicates advisories rather than refreshing them. The dedup check isif !*all && processedURLs[advisory.URL](main.go:148), so with--allthe run falls through toseqByYear[year]++(main.go:159) and allocates a newACSC-YYYY-Nfor an advisory that already has one. Observed: the 2026-08-06 18:30 AEST prod run mintedACSC-2026-0008andACSC-2026-0009for the same twosourceAdvisoryRefURLs already held byACSC-2026-0007andACSC-2026-0006. There is no unique constraint on (source,sourceAdvisoryRef) to catch it. The twins also disagree ondatePublishedby exactly 36,000s — the AEST offset — so one of each pair has the wrong publication time.- Only two years of sequence state are loaded.
main.go:114iteratesy := currentYear - 1; y <= currentYear, but the year is taken from the advisory’s ownpubDate(main.go:155). An item dated 2024 getsseqByYear[2024] == 0and is therefore minted asACSC-2024-0001, colliding with whatever already holds that id — andUpsertCVEMetadatawill overwrite it rather than fail.
Storage
No new tables or columns. All tables already exist.
| Table | Rows inserted |
|---|---|
CVEMetadata | One per advisory; source="acsc", cveId="ACSC-YYYY-N" |
CVEDescription | One per advisory; containerType="cna", lang="en" |
CVEMetadataReferences | One per advisory; type="advisory", referenceSource="acsc" |
GcveIssuance | One per advisory; gcveId="GCVE-110-ACSC-YYYY-N" |
GcveAlias | Advisory slug + each extracted CVE ID linked to GCVE ID |
CVEAlias | Extracted CVE IDs linked to ACSC identifier |
Incremental Strategy
On startup, load all sourceAdvisoryRef values from CVEMetadata where
source='acsc' into a map[string]bool. Per advisory: if the URL is in the
set and --all is false, skip.
Flags
| Flag | Default | Description |
|---|---|---|
--all | false | Reprocess all advisories, not just new ones |
--limit | 0 | Maximum advisories to process per run (0 = unlimited) |
HTTP Client
cyber.gov.au sits behind Lagoon + Signal Sciences (SigSci/Fastly) WAF.
SigSci’s IP reputation service flags AWS datacenter IP ranges as
“cloud/hosting” and silently hangs HTTP responses (TCP connects, TLS
completes, request is sent — but response headers never arrive). Browser
header spoofing (Sec-Fetch-*, Accept-Language, etc.) addresses TLS
fingerprinting checks but does not bypass IP reputation scoring.
This is no longer the case. The schedule is ENABLED — verified live
with aws scheduler get-schedule --name go-acsc-rss-processor (State
ENABLED, cron(30 15 * * ? *)) — and the Fargate runs fetch the feed
successfully: the 2026-08-05 15:30 UTC run in
/ecs/vdb-scheduler/acsc-rss-processor logs "feed fetched","items":2
followed by Outcome=success in 0.13s. Whatever SigSci was doing to us, the
forced HTTP/1.1 client plus the full browser header set below appears to have
resolved it. Keep the hardening; treat the paragraph above as history.
The local recipe (just go-acsc-rss-backfill) remains useful for reprocessing —
but see the --all hazard under ID Generation before using ALL=true
against production.
The HTTP client uses HTTP/1.1 (Lagoon sends HTTP/2
INTERNAL_ERROR stream resets to Go’s HTTP/2 implementation), 90s timeout,
30s dial, ResponseHeaderTimeout: 60s, full browser headers, gzip
decompression, and up to 5 retries with attempt * 5s backoff. The feed
is fetched before opening the DB pool because pgx background activity
was observed to cause additional hangs.
ECS Schedule
ENABLED — daily at 15:30 UTC, cron(30 15 * * ? *)
(terraform/go-schedules.tf:2904), 256 CPU / 512 MB,
expected_duration_minutes = 30. Typical run is ~0.13s and stores nothing,
because a 2-item feed is almost always unchanged.
The soft deadline is defaultTimeout = 30 * time.Minute unconditionally
(main.go:42, main.go:62), so unsetting EXPECTED_DURATION_MINUTES in the
backfill recipe does not remove it. Harmless at this feed size, but it is the
same hardcoded-margin pattern internal/rundeadline.Soft exists to replace.
Key Files
| File | Purpose |
|---|---|
cmd/acsc-rss-processor/main.go | Main processor |
internal/acsc/types.go | RSS feed and advisory Go structs |
internal/acsc/parser.go | Feed parsing, CVE extraction |
internal/acsc/mapper.go | Advisory → CVESourceData mapping |
schemas/acsc_rss_advisory.schema.json | JSON Schema Draft 7 for parsed advisory object |
S3 Persistence
Compliant. The generator printed “Not used” here because
scripts/docs/s3-status.yaml recorded status: none for this slug; that entry
was wrong (cf. ORCH-09) and has been corrected.
- Archive:
acsc/files/{sha256}/{ACSC-YYYY-N}.json—uploader.ArchiveRecord(ctx, source, payload)atmain.go:181, success path only, payload built bys3client.MarshalRecordatmain.go:168before the transaction opens so no S3 PUT holds a DB connection. - Quarantine:
failed-feeds/acsc-rss-processor/{YYYY-MM-DD}/{reason}/… - Failure reasons emitted:
parse-errorfor the whole feed body (main.go:84, before the DB pool is opened) andstore-errorper advisory (main.go:176).fetch-erroris not emitted — a fetch that fails all 5 attempts exits 1 atmain.go:79with no payload to store, which is contract-consistent.
Skipped entirely when S3_BUCKET_NAME is unset — s3client.NewFromEnv returns a
nil uploader and both methods are nil-receiver no-ops.