Design: Oracle CPU CSAF Processor
Overview
Discovers Oracle Critical Patch Update (CPU) bulletins (quarterly: Jan/Apr/Jul/Oct), fetches each CPU’s CSAF 2.0 JSON document plus per-bug HTML page for free-text enrichment, and writes one CVEMetadata row per vulnerability under source="oracle" keyed by OSA-{bugId}. Stages CRIT candidate envelopes per (cveID × ServiceMatch × CSAF product_status branch) for Oracle Cloud (OCI) and Oracle SaaS services. On-prem Oracle products (Database, WebLogic, Java SE) are stored but skipped at the CRIT stage — CRIT scope is cloud-resource attribution only.
Source identifier: oracle
Data type: csaf (CSAF 2.0 JSON + per-bug HTML enrichment)
ECS task name: go-oracle-cpu-csaf-processor
Schedule: Weekly Sunday 14:00 UTC (cron(0 14 ? * SUN *))
Pattern: B — batch (per-CPU, multiple vulnerabilities per CSAF document)
Phase: 1.5
CRIT staging is off in ECS.
--emit-critdefaults tofalsein the binary (main.go:53) and the task definition’s command is["/app/oracle-cpu-csaf-processor"]with no flags (terraform/go-schedules.tf,module "oracle_cpu_csaf_processor"). The scheduled weekly run therefore ingests CVEMetadata only — every CRIT envelope in production came from a manualjust go-oracle-cpu-csaf-backfill(whose recipe defaultsEMIT_CRIT=true) or fromcrit-inference-processor.scripts/task-manager.tomladvertisesemit-critwithdefault = "true", which describes the justfile recipe, not the schedule.
Flags
| Flag | Default | Description |
|---|---|---|
--force | false | Accepted and threaded into the store functions, but unused — nothing is hash-skipped (see R3) |
--limit | 0 | Cap the number of CPUs processed (0 = all) |
--from-year | 2022 | Earliest CPU year to discover. CSAF coverage starts April 2022; lower it to reach the HTML-only cohort |
--emit-crit | false | Stage CRIT candidate envelopes for cloud/SaaS products and drain them in-process before exit |
A soft deadline is applied only when EXPECTED_DURATION_MINUTES is set:
softDeadline = now + (mins − 10) and the CPU loop breaks at
softDeadline − 5 min (main.go:71-72, main.go:145), so the effective window
is mins − 15. Local backfills carry no deadline.
Data Source
CPU index
Oracle publishes CPUs at predictable URLs:
- HTML index:
https://www.oracle.com/security-alerts/cpu{month}{year}.html - CSAF JSON:
https://www.oracle.com/security-alerts/cpu{month}{year}/oracle-cpu-{month}{year}-csaf.json
CPUs older than April 2022 are HTML-only (CSAF era began with cpuapr2022). For pre-CSAF CPUs the processor falls back to extracting CVE-* IDs from the HTML and writing minimal CVEMetadata rows under source="oracle" (no aliases, no metrics, no descriptions, no CRIT) — the row’s value is the existence record + sourceFileHash for downstream cross-correlation.
CSAF document shape
{
"document": {
"tracking": {
"id": "CPU-2026-04",
"current_release_date": "2026-04-15T20:00:00Z",
"initial_release_date": "2026-04-15T20:00:00Z"
},
"title": "Oracle Critical Patch Update Advisory - April 2026",
"publisher": {"category": "vendor", "name": "Oracle"}
},
"product_tree": {
"branches": [
{
"category": "product_family",
"name": "Oracle Cloud Infrastructure",
"branches": [{"category": "product_name", "name": "Oracle Cloud Infrastructure Compute", "product": {"product_id": "oci-compute", "name": "OCI Compute"}}]
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2026-12345",
"title": "Vulnerability in OCI Compute",
"ids": [{"system_name": "Oracle Bug ID", "text": "37123456"}],
"scores": [{"cvss_v3": {"vector_string": "CVSS:3.1/AV:N/...", "base_score": 8.1, "base_severity": "HIGH"}}],
"notes": [{"category": "description", "text": "..."}],
"cwes": [{"id": "CWE-79", "name": "Cross-site Scripting"}],
"product_status": {
"known_affected": ["oci-compute"],
"fixed": []
},
"remediations": [
{"category": "vendor_fix", "details": "Apply the April 2026 CPU.", "product_ids": ["oci-compute"]}
],
"references": [{"url": "https://www.oracle.com/...", "category": "external"}]
}
]
}
Per-vulnerability splitting: each vulnerabilities[i] becomes one CVEMetadata row under source="oracle" keyed by OSA-{bugId} (where bugId is the first ids[] entry whose system_name == "Oracle Bug ID").
Per-bug HTML enrichment
The CPU’s HTML index page carries free-text “Notes” / “Justification” cells per bug ID. The processor extracts:
Justification— VEX-equivalent narrative.AdvisoryURL—{cpuHTMLURL}#{bugId}anchor.PatchedVersions,AffectedVersions, free-textNotes.
This data enriches the description rows (the CSAF doesn’t carry the same narrative).
Architecture / Data Flow
Source → Database Mappings
CVEMetadata (per-vulnerability path)
| CSAF field | Column | Notes |
|---|---|---|
OSA-{bugId} | cveId | bugId from vulnerabilities[i].ids[] where system_name=="Oracle Bug ID". One row per vulnerabilities[i], not per CPU. |
"oracle" | source | Constant. |
"2.0" | dataVersion | CSAF spec version. |
"PUBLISHED" | state | Constant. |
document.tracking.current_release_date (Unix sec) | datePublished | Per-CPU release date — same across all vulns in a CSAF. |
vulnerabilities[].title (else "Oracle CPU {CPU} — {CVE}") | title | |
{cpuHTMLURL}#{bugId} (HTML enrichment URL when present) | sourceAdvisoryRef | Per-vuln anchor URL. |
FirstCVSSVector(v) | vectorString | Highest-scored vector — V3 preferred over V2. |
sha1({fullCSAFHash}:{vIdx}) | sourceFileHash | Per-vuln resume detection. |
CVEMetadata (HTML-only fallback path, pre-2022 CPUs)
| Field | Column |
|---|---|
| CVE-* extracted from HTML | cveId |
"oracle" | source |
"html-1.0" | dataVersion |
"Oracle CPU {CPU} (HTML-only) — {CVE}" | title |
cpuHTMLURL | sourceAdvisoryRef |
No aliases / metrics / descriptions / CRIT for the HTML-only path.
storeOracleHTMLOnly also supplies no DatePublished, so every row on this
path lands with datePublished = 0 (1 115 such rows in production as of
2026-08). Reachable only when --from-year is lowered below 2022.
Rows this processor does not write
Deliberate gaps, recorded here because their absence is easy to mistake for a bug:
rawDataJSONis never populated.writeOracleVulnomits the column entirely (store.go:83-92), so allsource='oracle'rows haverawDataJSON IS NULL. The CSAF bytes live only in S3, once per CPU.- No
CVEAffected/affectedVendor/affectedProduct. The product tree is walked for CRIT service resolution only; nothing is projected into the affected tables, soaffectedProduct IS NULLon everyoraclerow. - No
Artifact/Linkrows, and therefore noCVEMetadata.fileLinkId— the S3 archive is a barePutObject.
Alias-minted placeholder rows
db.InsertAliases creates a shell CVEMetadata row for any alias target that
does not already exist, under the aliased source. Because R13 emits
OSA-{otherBugId} aliases for every additional bug id on a vulnerability, and
those secondary bug ids are usually not the FirstBugID of any vulnerability,
they become content-free source='oracle' rows: dataVersion='5.1', no title,
datePublished = 0. Production holds 3 253 of these against 4 705 real
CSAF-era rows — 36 % of the oracle footprint.
CVEAlias
For each vulnerabilities[i]:
- The
cvefield becomes the canonical CVE alias. - Every other Oracle Bug ID referenced by the vuln (
AllBugIDs(v)minus the primarybugID) gets prefixed withOSA-and added.
db.InsertAliases(ctx, tx, osaID, "oracle", aliasIDs, slogNop()) — InsertAliases canonicalises edge direction (CVE primary, OSA alias).
CVEReference
- Primary advisory ref:
{cpuHTMLURL}#{bugId}with typeadvisory. - Every URL in
vulnerabilities[i].references[](external, self) added with the originalcategoryvalue.
CVEDescription
- Each
vulnerabilities[i].notes[]entry wherecategory ∈ {description, summary, details}→ one row withcontainerType="cna",lang="en". - HTML
Justificationcell (when present) → one extra row prefixed"Oracle CPU notes: ".
CVEMetric
For each vulnerabilities[i].scores[]:
cvss_v3(whenvector_string != "") →metricType="cvssV3_1".cvss_v2(whenvector_string != "") →metricType="cvssV2_0".
CVEProblemType
For each vulnerabilities[i].cwes[]:
cweID = c.ID,description = c.Name,descriptionType="CWE".
S3 archive
oracle/files/{cpuName}/csaf.json
Per-CPU not per-vulnerability — saves S3 write count (one CSAF doc covers many vulns).
Service Mapping
service_map.go resolves Oracle SaaS / OCI / on-prem product mentions in product_tree branches.
On-prem detection (filtered out of CRIT)
onPremHints — when ANY product name matches one of these, seenOnPrem=true. CVEMetadata still lands; CRIT is suppressed:
- “oracle database 19c”, “oracle database 21c”, “oracle database 23c”
- “oracle weblogic server”
- “oracle java se”, “oracle jdk”, “oracle jre”
- “oracle solaris”
- “oracle linux”
- “myql server” (on-prem MySQL)
- “oracle berkeley db”
Cloud / SaaS resolution (60+ synonyms)
| Product hint (lc) | Service | Resource type | Dict |
|---|---|---|---|
| “oracle cloud infrastructure compute” / “oci compute” | compute | instance | spec |
| “oracle autonomous database” | autonomous_database | instance | spec |
| “oci object storage” / “object storage service” | object_storage | bucket | spec |
| “oci block volume” / “block storage” | block_storage | volume | spec |
| “oci file storage” | file_storage | file-system | spec |
| “oracle container engine for kubernetes” / “oke” | oke | cluster | spec |
| “oci functions” | functions | function | spec |
| “oci api gateway” | api_gateway | gateway | spec |
| “oracle fusion applications” / “fusion middleware” (cloud variant) | fusion | application | extended |
| “oracle hcm cloud” / “human capital management cloud” | hcm_cloud | tenant | extended |
| “oracle erp cloud” | erp_cloud | tenant | extended |
| “oracle scm cloud” | scm_cloud | tenant | extended |
| “oracle cx cloud” / “service cloud” / “sales cloud” / “marketing cloud” (Oracle’s, not Salesforce’s) | cx_cloud | tenant | extended |
| “oracle netsuite” | netsuite | account | extended |
| “oracle analytics cloud” / “obi cloud” | analytics_cloud | instance | extended |
| “oracle integration cloud” / “oic” | integration | integration | extended |
| “oracle e-business suite” (Cloud) | ebs_cloud | tenant | extended |
| “oracle cpq cloud” | cpq_cloud | tenant | extended |
| “oracle peoplesoft cloud” | peoplesoft_cloud | tenant | extended |
| “oracle jd edwards cloud” / “jde cloud” | jde_cloud | tenant | extended |
… (full table in service_map.go) |
Extended-dict layer (internal/critutil/dictionaries/extended/oracle.json): 13 entries with template_format=oracle_ocid.
Resolve returns
matches []ServiceMatch
hasCloud bool // at least one cloud match found
seenOnPrem bool // at least one on-prem hint seen
When hasCloud=false && seenOnPrem=true, the producer logs "crit skipped: on-prem Oracle product" and stores no CRIT envelope. When neither is true, the producer logs "crit skipped: unmapped product" (extended-dict gap).
Business Rules
Discovery (index.go:DiscoverCPUs)
| Rule | Conditional |
|---|---|
R1 Iterate quarterly months from fromYear to current | hard-coded ["jan","apr","jul","oct"] × [fromYear...currentYear]. |
| R2 CSAF availability flag derived from URL pattern | cpu.HasCSAF() returns true iff CPU year ≥ 2022 AND month ≥ apr2022. |
CPU processing (main.go:processCPU)
| Rule | Conditional |
|---|---|
R3 processCPU never loads a resume set and never compares cpuHash against anything. --force is threaded into storeOracleVuln / storeOracleHTMLOnly (store.go:35, store.go:231) but is unused in both. Every run re-fetches every CPU HTML + CSAF and re-upserts every vulnerability; the computed cpuHash is only ever written to sourceFileHash. | |
| R4 Pre-CSAF CPUs use HTML-only fallback | if csafDoc != nil { CSAF path } else { extractCVEsFromHTML path } — reachable only when --from-year is set below 2022, since discovery starts at --from-year (default 2022). |
| R5 Skip vuln when no bug ID and no CVE | if bugID == "" && v.CVE == "" { logger.Warn; continue } |
| R6 OSA-ID derivation | osaID = "OSA-" + bugID when bugID present; else osaID = fmt.Sprintf("OSA-%s-%s%d", strings.TrimPrefix(v.CVE, "CVE-"), cpu.Month, cpu.Year). |
R7 Per-vuln hash = hash:vIdx | perVulnHash := fmt.Sprintf("%s:%d", hash, vIdx) so resume detection works at vuln granularity. |
CSAF parsing (csaf.go)
| Rule | Conditional |
|---|---|
| R8 Highest-scored CVSS vector wins | if bestV3 == nil || s.CVSSV3.BaseScore > bestV3.CVSSV3.BaseScore { bestV3 = s } (V3 preferred over V2 unconditionally) |
| R9 Nil-guard CVSS pointer dereferences | if s.CVSSV3 != nil && s.CVSSV3.VectorString != "" — CSAF V3 score is optional (not all vulns carry V3). |
| R10 Bug-ID lookup | if strings.HasPrefix(id.SystemName, "Oracle Bug ID") { ... } — exact-prefix match, case-sensitive. |
| R11 Empty CSAF doc returns nil | if doc == nil { return idx } (graceful no-op for failed fetch). |
Storage (store.go:writeOracleVuln)
| Rule | Conditional |
|---|---|
| R12 Title fallback | if title == "" { title = fmt.Sprintf("Oracle CPU %s — %s", cpu, v.CVE) } |
| R13 Aliases include CVE + every other Oracle Bug ID | aliasIDs := []string{v.CVE}; for _, alt := range AllBugIDs(v) { if alt != bugID { aliasIDs = append(aliasIDs, "OSA-"+alt) } } |
| R14 Description note categories filtered | if cat != "description" && cat != "summary" && cat != "details" { continue } |
| R15 Metric rows skip empty vectors | if s.CVSSV3 != nil && s.CVSSV3.VectorString != "" (then for V2 same guard) |
| R16 ProblemType skips empty CWE IDs | if c.ID == "" { continue } |
Service mapping (service_map.go:Resolve)
| Rule | Conditional |
|---|---|
R17 On-prem hint flips seenOnPrem flag | for _, h := range onPremHints { if strings.Contains(text, h) { seenOnPrem = true } } |
R18 First synonym match per (service, resource_type) wins | Deduped via seen[key] = true. |
R19 hasCloud flips when ANY synonym matches | hasCloud = true set inside the per-synonym match. |
CRIT mapper (crit_mapper.go:mapOracleToCRIT)
| Rule | Conditional |
|---|---|
| R20 Reject when both CVE and OSA empty | if cveID == "" && osaID == "" { return ok=false } |
| R21 vulnID prefers CVE over OSA | if vulnID == "" { vulnID = osaID } |
| R22 vex_status mapping from CSAF status | vexStatusFromCSAF(csafVEXStatus) — fixed→fixed, known_affected→affected, known_not_affected→not_affected, under_investigation→under_investigation, default→fixed. |
| R23 fix_propagation by CSAF remediation category | fixPropFromCSAF(category, service) — mitigation/workaround → config_change, none_available/no_fix_planned → no_fix_available, vendor_fix/unknown → service-shape default. |
| R24 Customer-managed services default to version_update | compute / database / oke → version_update. |
| R25 SaaS services default to automatic | fusion, hcm_cloud, erp_cloud, scm_cloud, cx_cloud, netsuite, analytics_cloud, marketing_cloud, cpq_cloud, peoplesoft_cloud, jde_cloud, ebs_cloud → automatic. |
| R26 Default OCI managed services to automatic | default → automatic (autonomous_db, functions, api_gateway, etc). |
| R27 Lifecycle by service shape | compute/database/oke → stateful_customer, functions/container_instances/data_science → ephemeral, identity/key_management/vault/cloud_guard/events/notifications/logging/monitoring/dns → global_control_plane, SaaS → stateful_managed. |
| R28 Reject when dictionary entry missing | if template == "" { return ok=false } |
| R29 ServiceAvailableDate fallback to OCI launch (2016-10-20) | if !found { saDate = "2016-10-20" } |
Coupling rules
- R30
automatic⇒provider_only. - R31
existing_deployments_remain_vulnerable = !(provider_only && automatic).
Drain (main.go)
- R32 Drain skipped on
CRIT_DISABLE_INPROCESS_DRAIN. - R33 4 workers.
Verification Queries
-- OSA-prefixed primary keys
SELECT count(*) FROM "CVEMetadata" WHERE source='oracle' AND "cveId" LIKE 'OSA-%';
-- HTML-only fallback rows (pre-2022 CPUs)
SELECT count(*) FROM "CVEMetadata" WHERE source='oracle' AND "dataVersion"='html-1.0';
-- CVE alias edges
SELECT count(*) FROM "CVEAlias"
WHERE "discoveredFrom"='oracle' AND "primaryCveId" LIKE 'CVE-%';
-- Service distribution — should be Oracle Cloud / SaaS only (on-prem skipped)
SELECT service, count(*) FROM "CritRecord"
WHERE provider='oracle' GROUP BY 1 ORDER BY 2 DESC;
-- No on-prem leakage (e.g. service='database' for on-prem Database)
-- on-prem 'compute' / 'database' should be 0 for OSA-* with CRIT
SELECT count(*) FROM "CritRecord"
WHERE provider='oracle' AND service IN ('database')
AND "critJSON"->'evidence'->>'on-prem' IS NOT NULL;
-- Pending should be empty
SELECT "processingStatus", count(*) FROM "S3QueueObject"
WHERE source='oracle-cpu-csaf-processor' GROUP BY 1;
Risk Surface
| Risk | Guard |
|---|---|
| Oracle changes CSAF URL pattern | Discovery probes both HTML and CSAF; pre-CSAF fallback path keeps CPUs flowing for HTML-only era. |
| CSAF V3 / V2 score pointers nil | Nil-guarded at every dereference (R9). |
OSA-{bugId} collision when same bug in two CPUs | Different per-vuln hash (R7); UPSERT idempotent. |
| On-prem Oracle product accidentally CRIT-staged | seenOnPrem tracked at Resolve time; CRIT skipped with explicit log. |
| Pre-2022 HTML scrape breaks | HTML-only path is best-effort; CVE list is the only thing extracted; failure is logged. |
| CPU index page redesign | Falls back to slug-based URL pattern; index parser failure logs but continues processing the rest. |
S3 Persistence
- Archive path:
oracle/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/oracle-cpu-csaf-processor/{YYYY-MM-DD}/{reason}/{filename}⚠ not yet wired - Failure reasons emitted:
_(none wired)_
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.