Design: Siemens CSAF Processor
Overview
Fetches Siemens ProductCERT security advisories published in CSAF 2.0 (Common Security Advisory Framework) JSON format from the public TLP:WHITE feed index and stores the parsed vulnerability data into the VDB PostgreSQL database.
Source identifier: siemens
Data type: csaf
ECS task name: go-siemens-csaf-processor
Schedule: Runs weekly on Fridays at 03:00 UTC (cron(0 3 ? * FRI *)).
Data Source
| Item | Value |
|---|---|
| Feed index URL | https://cert-portal.siemens.com/productcert/csaf/ssa-feed-tlp-white.json |
| Advisory format | CSAF 2.0 JSON (OASIS standard) |
| Advisory schema | schemas/csaf_2.0.schema.json |
| Advisory count | ~900+ (as of 2026) |
| License | TLP:WHITE (publicly accessible) |
| Update frequency | Weekly per advisory; feed updated daily |
Feed Index Structure
{
"feed": {
"id": "siemens-security-advisories-csaf-feed-tlp-white",
"entry": [
{
"id": "SSA-027652",
"title": "...",
"published": "2025-09-09T00:00:00Z",
"updated": "2026-03-10T00:00:00Z",
"link": [{ "rel": "self", "href": "https://cert-portal.siemens.com/productcert/csaf/ssa-027652.json" }],
"summary": { "content": "..." }
}
]
}
}
CSAF Advisory Structure
Each advisory JSON follows the CSAF 2.0 standard:
document.tracking.id— Advisory ID (e.g.SSA-027652)document.tracking.initial_release_date/current_release_date— Publication datesdocument.title— Advisory titledocument.references[]— Document-level referencesvulnerabilities[]— One or more CVE entries, each with:cve— CVE identifierscores[]— CVSS v2/v3/v4 scoringnotes[]— Descriptions and summariescwes[]— CWE identifiersproduct_status.known_affected[]— Affected product IDsreferences[]— Per-vulnerability referencesremediations[]— Fix/workaround information with URLs
product_tree— Hierarchical product catalog (branches/full_product_names)
Data Mapping
Primary-key Modelling: SSA-keyed rows
Siemens publishes the same CVE in multiple SSA bulletins (different products, different disclosure cycles). Keying CVEMetadata on the CVE collapses every SSA covering that CVE into a single last-writer-wins row, losing per-advisory product/affected lists. Following the Red Hat pattern (see redhat-csaf-processor.design.md §4):
- One
CVEMetadatarow per advisory, keyed ondocument.tracking.id(e.g.SSA-027652) withsource='siemens'. - Every
vulnerabilities[]entry in the advisory is aggregated into the one row — descriptions, metrics, references, CWEs, and affected products are unioned (deduped). - Each
vulnerabilities[].cvebecomes aCVEAliasrow viadata.Aliases.db.InsertAliasescanonicalises edge direction and emits same-cveId cross-source edges so a consumer querying forCVE-Xcan resolve to every Siemens SSA covering it. - Multi-SSA-per-CVE is now expressible: two SSAs each have their own row and both alias the same
CVE-*.
CVEMetadata (one row per advisory)
| CSAF field | CVEMetadata field | Notes |
|---|---|---|
document.tracking.id | cveId | SSA-XXXXXX — primary key component |
"siemens" | source | Constant |
document.tracking.initial_release_date | datePublished | Unix seconds |
document.tracking.current_release_date | dateUpdated | Unix seconds; null if same as published |
document.title | title | Falls back to SSA ID when empty |
"PUBLISHED" | state | All Siemens feed entries are published |
"5.0" | dataVersion | CSAF 2.0 maps to CVE schema version 5.0 |
| Self-link href from feed entry | sourceAdvisoryRef | Canonical advisory URL |
| Advisory SHA256 | sourceFileHash | For resume/dedup |
| Serialized CSAF JSON | rawDataJSON | Full advisory stored as JSON |
Link.id | fileLinkId | FK to artifact download record |
CVEAlias
For every advisory:
- Every
vulnerabilities[].cveis added toCVESourceData.Aliases. - Any
vulnerabilities[].ids[].textthat begins withCVE-is also added (defensive — some Siemens advisories duplicate the CVE there). db.InsertAliaseswrites one edge per matching(aliasCveId, aliasSource)row found inCVEMetadata, and additionally emits same-cveId cross-source edges (e.g.(SSA-..., siemens) ↔ (CVE-..., nist-nvd)is created indirectly via the alias chain).
CVEDescription
- Source:
vulnerabilities[].noteswherecategoryisdescription,summary, orgeneral - Falls back to
document.notesif vulnerability has no matching notes containerType = "cna",lang = "en"
CVEMetadataReferences
All references are deduplicated by URL across document + vulnerability + remediation levels:
| Source | Type logic |
|---|---|
| Advisory self-link | advisory |
references[].category == "self" | advisory |
references[].category == "external" + URL contains exploit/poc | exploit |
references[].category == "external" | advisory |
remediations[].category == "vendor_fix"/"patch" | patch |
remediations[] other | vendor |
| URL contains “exploit”/“poc”/“proof-of-concept” | exploit |
| URL contains “patch”/“fix”/“commit”/“pull/” | patch |
ReferenceSource = "siemens" on all rows.
CVEMetric
One row per unique CVSS vector string:
| CSAF field | CVEMetric field |
|---|---|
scores[].cvss_v4.vectorString | vectorString, metricType = "cvssV4_0" |
scores[].cvss_v3.vectorString | vectorString, metricType = "cvssV3_1" or "cvssV3_0" |
scores[].cvss_v2.vectorString | vectorString, metricType = "cvssV2_0" |
baseScore | baseScore |
baseSeverity | baseSeverity |
containerType = "cna" | all rows |
CVEProblemType
| CSAF field | CVEProblemType field |
|---|---|
vulnerabilities[].cwes[].id | cweId |
vulnerabilities[].cwes[].name | description |
"CWE" | descriptionType |
"cna" | containerType |
CVEAffected
Products from product_status.known_affected across every vulnerability in the advisory are unioned into the single SSA row, deduped by affectedHash:
vendor = "Siemens"product= resolved fromproduct_treeby product IDaffectedHash= MD5(vendor|##|product|##||##|)containerType = "cna"
Artifact + Link
- Artifact:
type = "OTHER",bomFormat = "siemens", R2 bucket/key set - S3 key:
siemens/advisories/{sha256}/{ssaId}.json - Link:
contentType = "PLAIN_JSON", URL = advisory self-link CVEMetadata.fileLinkIdpoints to the Link record
DEFERRED advisories — the dominant row class
A feed entry whose advisory URL returns a permanent HTTP error (403 / 404 / 410)
is not retried. storeDeferred (main.go:412) instead writes a placeholder row
keyed on the SSA id with state='DEFERRED', dataVersion='5.0' and
sourceAdvisoryRef set — no title, no rawDataJSON, no metrics, no affected
products. Because that path leaves DatePublished nil, storeAdvisory
(internal/processor/pipeline.go:430-434) passes 0 and the row lands with
datePublished = 0.
Siemens’ TLP:WHITE feed lists a large tail of advisories that the portal no
longer serves anonymously, so this is not a rare path: as of 2026-08 production
holds 494 DEFERRED rows against 327 PUBLISHED ones — 60 % of the siemens
footprint carries nothing but an id and a URL, and 444 of those have
datePublished = 0.
Worse, 50 of those DEFERRED rows carry a non-zero datePublished, which means
they were ingested successfully at some point and later downgraded:
UpsertCVEMetadata sets title, vectorString and rawDataJSON from
EXCLUDED on conflict, so a re-run that gets a 403 for a previously-good
advisory replaces its content with NULL while keeping the earlier
datePublished. Their fetchCount values (5–8) show the overwrite repeating on
every run. This is a defect, not intended behaviour — see the efficacy record for
siemens-csaf-processor.
Incremental Strategy
- Feed-level: SHA256 of the full feed index JSON stored in
BulkDataDumpTrackerwith keysiemens_csaf_listing. Run is skipped entirely if unchanged (unless--force). - Advisory-level: SHA256 of each advisory JSON stored in
CVEMetadata.sourceFileHash, keyed by(SSA, siemens). Advisory is skipped if hash matches existing record (unless--force).
This means ECS runs are O(1) network + O(changed) DB writes on most days.
Flags
| Flag | Default | Description |
|---|---|---|
--force | false | Reprocess all advisories regardless of SHA256 match |
--limit | 0 | Cap advisory count (0 = all; useful for testing) |
--workers | 5 | Concurrent fetch workers sharing a single 200 ms ticker (≈5 req/s) |
Processing Pipeline
main()
├─ Fetch feed index (5-attempt retry, 30 s per attempt)
├─ Parse feed → []FeedEntry
├─ Check feed SHA256 vs BulkDataDumpTracker → skip if unchanged (NoWork)
├─ db.LoadProcessedHashes(source="siemens") → resume map (SSA id → sha256)
├─ Dispatcher goroutine → workCh
├─ N=--workers fetch workers, each:
│ ├─ stop dispatching once (softDeadline − 10 min) has passed
│ ├─ wait for the shared 200 ms ticker
│ ├─ Fetch advisory JSON (5 attempts; 403/404/410 → DEFERRED, no retry)
│ ├─ siemens.ParseAdvisory() (failure → S3 quarantine `parse-error`)
│ ├─ siemens.MapAdvisory() → *osv.CVESourceData — ONE record per advisory
│ │ (nil → S3 quarantine `schema-violation`)
│ └─ Skip when resumeSet[ssaID] == advisory SHA256
├─ Single consumer goroutine, per advisory:
│ └─ storeItem: one transaction per advisory, up to 3 attempts
│ ├─ S3 upload → InsertArtifact → InsertLinkWithArtifact
│ ├─ processor.StoreCVESourceData() [shared pipeline]
│ └─ UpdateCVEMetadataFileLinkID() [best-effort]
└─ db.UpsertTracker(siemens_csaf_listing, newSHA256, processedCount)
siemens.MapAdvisory returns a single *osv.CVESourceData for the whole
advisory (internal/siemens/mapper.go:24), consistent with the SSA-keyed
modelling above. There is no 50-item batch and no per-item savepoint — every
advisory gets its own short transaction with a 2-minute context.
Soft deadline
softDeadline = now + (EXPECTED_DURATION_MINUTES − 10), defaulting to
now + 90 min when the env var is unset; workers then stop dispatching at
softDeadline − 10 min. Under the 60-minute ECS budget the effective work window
is 40 minutes. The unconditional 90-minute fallback also caps
just go-siemens-csaf-backfill at 80 minutes.
Files
| Path | Description |
|---|---|
scripts/go-processors/cmd/siemens-csaf-processor/main.go | ECS task entry point |
scripts/go-processors/cmd/siemens-csaf-processor/s3.go | S3 uploader factory |
scripts/go-processors/internal/siemens/types.go | CSAF + feed Go structs |
scripts/go-processors/internal/siemens/parser.go | Feed/advisory JSON parsers |
scripts/go-processors/internal/siemens/mapper.go | CSAF → CVESourceData mapping |
schemas/csaf_2.0.schema.json | CSAF 2.0 JSON Schema (reference) |
terraform/go-schedules.tf | EventBridge + ECS task definition |
scripts/task-manager.toml | TUI task configuration |
Local Development
# Incremental run (default — processes only new/changed advisories)
just go-siemens-csaf-backfill
# Full backfill of all advisories
just go-siemens-csaf-backfill FORCE="true"
# Limit to first 10 advisories (quick test)
just go-siemens-csaf-backfill LIMIT="10"
# Run in prod environment
just go-siemens-csaf-backfill TARGET="prod" LIMIT="5"
# Run in local ARM64 container (mirrors ECS)
just siemens-csaf-processor
Deployment
The processor is built as part of Containerfile.go-processors (target: siemens-csaf-processor) and pushed to ECR as go-siemens-csaf-processor-latest. The ECS task definition and EventBridge schedule are managed in terraform/go-schedules.tf under module "siemens_csaf_processor".
Known Limitations
- Multi-CVE advisories aggregate every
vulnerabilities[]entry into a single SSA-keyed row. Per-CVE provenance within an advisory is recoverable only from the raw CSAF JSON in S3 /CVEMetadata.rawDataJSON. - Consumers querying by
CVE-*must followCVEAliasto discover Siemens advisories — the canonical primary key is the SSA ID, not the CVE. - Some older advisories may not follow strict CSAF 2.0 (notes/references may differ). The parser is tolerant of missing fields.
- Product names resolved from
product_treemay be verbose (full platform strings). TheaffectedHashdeduplicates by vendor+product pair.
S3 Persistence
- Archive path:
siemens/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/siemens-csaf-processor/{YYYY-MM-DD}/{reason}/{filename}✓ - Failure reasons emitted:
parse-error,schema-violation
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.