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-crit defaults to false in 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 manual just go-oracle-cpu-csaf-backfill (whose recipe defaults EMIT_CRIT=true) or from crit-inference-processor. scripts/task-manager.toml advertises emit-crit with default = "true", which describes the justfile recipe, not the schedule.


Flags

FlagDefaultDescription
--forcefalseAccepted and threaded into the store functions, but unused — nothing is hash-skipped (see R3)
--limit0Cap the number of CPUs processed (0 = all)
--from-year2022Earliest CPU year to discover. CSAF coverage starts April 2022; lower it to reach the HTML-only cohort
--emit-critfalseStage 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-text Notes.

This data enriches the description rows (the CSAF doesn’t carry the same narrative).


Architecture / Data Flow

sequenceDiagram participant ECS participant Proc as oracle-cpu-csaf-processor participant Oracle as oracle.com/security-alerts participant DB as PostgreSQL participant S3 ECS->>Proc: cron(0 14 ? * SUN *) Proc->>Oracle: DiscoverCPUs (since fromYear) Oracle-->>Proc: []CPUEntry (with HasCSAF flag) loop per CPU (sequential, 500ms gap) Proc->>Oracle: FetchHTML (cpuYYYYMM.html) Oracle-->>Proc: HTML Proc->>Proc: parseHTMLContext → map[bugID]HTMLContext alt CPU has CSAF (April 2022+) Proc->>Oracle: FetchCSAF Oracle-->>Proc: CSAFDocument Proc->>Proc: BuildProductIndex (product_tree walk) loop per vulnerability in csafDoc.Vulnerabilities Proc->>Proc: bugID = FirstBugID(v) (else CVE-derived id) Proc->>Proc: osaID = "OSA-" + bugID Proc->>DB: storeOracleVuln (CVEMetadata + Aliases + References + Metrics + CWEs + Descriptions) Proc->>S3: archive (per-CPU csaf.json — shared across vulns) opt --emit-crit AND service map resolves AND hasCloud Proc->>Proc: stageCRIT (per-match) end end else pre-CSAF (HTML-only) Proc->>Proc: extractCVEsFromHTML loop per CVE Proc->>DB: storeOracleHTMLOnly (minimal CVEMetadata; no CRIT) end end end Proc->>Proc: critpublisher.DrainKeys

Source → Database Mappings

CVEMetadata (per-vulnerability path)

CSAF fieldColumnNotes
OSA-{bugId}cveIdbugId from vulnerabilities[i].ids[] where system_name=="Oracle Bug ID". One row per vulnerabilities[i], not per CPU.
"oracle"sourceConstant.
"2.0"dataVersionCSAF spec version.
"PUBLISHED"stateConstant.
document.tracking.current_release_date (Unix sec)datePublishedPer-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)sourceAdvisoryRefPer-vuln anchor URL.
FirstCVSSVector(v)vectorStringHighest-scored vector — V3 preferred over V2.
sha1({fullCSAFHash}:{vIdx})sourceFileHashPer-vuln resume detection.

CVEMetadata (HTML-only fallback path, pre-2022 CPUs)

FieldColumn
CVE-* extracted from HTMLcveId
"oracle"source
"html-1.0"dataVersion
"Oracle CPU {CPU} (HTML-only) — {CVE}"title
cpuHTMLURLsourceAdvisoryRef

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:

  • rawDataJSON is never populated. writeOracleVuln omits the column entirely (store.go:83-92), so all source='oracle' rows have rawDataJSON 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, so affectedProduct IS NULL on every oracle row.
  • No Artifact / Link rows, and therefore no CVEMetadata.fileLinkId — the S3 archive is a bare PutObject.

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 cve field becomes the canonical CVE alias.
  • Every other Oracle Bug ID referenced by the vuln (AllBugIDs(v) minus the primary bugID) gets prefixed with OSA- 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 type advisory.
  • Every URL in vulnerabilities[i].references[] (external, self) added with the original category value.

CVEDescription

  • Each vulnerabilities[i].notes[] entry where category ∈ {description, summary, details} → one row with containerType="cna", lang="en".
  • HTML Justification cell (when present) → one extra row prefixed "Oracle CPU notes: ".

CVEMetric

For each vulnerabilities[i].scores[]:

  • cvss_v3 (when vector_string != "") → metricType="cvssV3_1".
  • cvss_v2 (when vector_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)ServiceResource typeDict
“oracle cloud infrastructure compute” / “oci compute”computeinstancespec
“oracle autonomous database”autonomous_databaseinstancespec
“oci object storage” / “object storage service”object_storagebucketspec
“oci block volume” / “block storage”block_storagevolumespec
“oci file storage”file_storagefile-systemspec
“oracle container engine for kubernetes” / “oke”okeclusterspec
“oci functions”functionsfunctionspec
“oci api gateway”api_gatewaygatewayspec
“oracle fusion applications” / “fusion middleware” (cloud variant)fusionapplicationextended
“oracle hcm cloud” / “human capital management cloud”hcm_cloudtenantextended
“oracle erp cloud”erp_cloudtenantextended
“oracle scm cloud”scm_cloudtenantextended
“oracle cx cloud” / “service cloud” / “sales cloud” / “marketing cloud” (Oracle’s, not Salesforce’s)cx_cloudtenantextended
“oracle netsuite”netsuiteaccountextended
“oracle analytics cloud” / “obi cloud”analytics_cloudinstanceextended
“oracle integration cloud” / “oic”integrationintegrationextended
“oracle e-business suite” (Cloud)ebs_cloudtenantextended
“oracle cpq cloud”cpq_cloudtenantextended
“oracle peoplesoft cloud”peoplesoft_cloudtenantextended
“oracle jd edwards cloud” / “jde cloud”jde_cloudtenantextended
… (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)

RuleConditional
R1 Iterate quarterly months from fromYear to currenthard-coded ["jan","apr","jul","oct"] × [fromYear...currentYear].
R2 CSAF availability flag derived from URL patterncpu.HasCSAF() returns true iff CPU year ≥ 2022 AND month ≥ apr2022.

CPU processing (main.go:processCPU)

RuleConditional
R3 Skip CPUs whose hash hasn’t changednot implemented. 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 fallbackif 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 CVEif bugID == "" && v.CVE == "" { logger.Warn; continue }
R6 OSA-ID derivationosaID = "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:vIdxperVulnHash := fmt.Sprintf("%s:%d", hash, vIdx) so resume detection works at vuln granularity.

CSAF parsing (csaf.go)

RuleConditional
R8 Highest-scored CVSS vector winsif bestV3 == nil || s.CVSSV3.BaseScore > bestV3.CVSSV3.BaseScore { bestV3 = s } (V3 preferred over V2 unconditionally)
R9 Nil-guard CVSS pointer dereferencesif s.CVSSV3 != nil && s.CVSSV3.VectorString != "" — CSAF V3 score is optional (not all vulns carry V3).
R10 Bug-ID lookupif strings.HasPrefix(id.SystemName, "Oracle Bug ID") { ... } — exact-prefix match, case-sensitive.
R11 Empty CSAF doc returns nilif doc == nil { return idx } (graceful no-op for failed fetch).

Storage (store.go:writeOracleVuln)

RuleConditional
R12 Title fallbackif title == "" { title = fmt.Sprintf("Oracle CPU %s — %s", cpu, v.CVE) }
R13 Aliases include CVE + every other Oracle Bug IDaliasIDs := []string{v.CVE}; for _, alt := range AllBugIDs(v) { if alt != bugID { aliasIDs = append(aliasIDs, "OSA-"+alt) } }
R14 Description note categories filteredif cat != "description" && cat != "summary" && cat != "details" { continue }
R15 Metric rows skip empty vectorsif s.CVSSV3 != nil && s.CVSSV3.VectorString != "" (then for V2 same guard)
R16 ProblemType skips empty CWE IDsif c.ID == "" { continue }

Service mapping (service_map.go:Resolve)

RuleConditional
R17 On-prem hint flips seenOnPrem flagfor _, h := range onPremHints { if strings.Contains(text, h) { seenOnPrem = true } }
R18 First synonym match per (service, resource_type) winsDeduped via seen[key] = true.
R19 hasCloud flips when ANY synonym matcheshasCloud = true set inside the per-synonym match.

CRIT mapper (crit_mapper.go:mapOracleToCRIT)

RuleConditional
R20 Reject when both CVE and OSA emptyif cveID == "" && osaID == "" { return ok=false }
R21 vulnID prefers CVE over OSAif vulnID == "" { vulnID = osaID }
R22 vex_status mapping from CSAF statusvexStatusFromCSAF(csafVEXStatus)fixed→fixed, known_affected→affected, known_not_affected→not_affected, under_investigation→under_investigation, default→fixed.
R23 fix_propagation by CSAF remediation categoryfixPropFromCSAF(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_updatecompute / database / oke → version_update.
R25 SaaS services default to automaticfusion, 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 automaticdefault → automatic (autonomous_db, functions, api_gateway, etc).
R27 Lifecycle by service shapecompute/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 missingif template == "" { return ok=false }
R29 ServiceAvailableDate fallback to OCI launch (2016-10-20)if !found { saDate = "2016-10-20" }

Coupling rules

  • R30 automaticprovider_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

RiskGuard
Oracle changes CSAF URL patternDiscovery probes both HTML and CSAF; pre-CSAF fallback path keeps CPUs flowing for HTML-only era.
CSAF V3 / V2 score pointers nilNil-guarded at every dereference (R9).
OSA-{bugId} collision when same bug in two CPUsDifferent per-vuln hash (R7); UPSERT idempotent.
On-prem Oracle product accidentally CRIT-stagedseenOnPrem tracked at Resolve time; CRIT skipped with explicit log.
Pre-2022 HTML scrape breaksHTML-only path is best-effort; CVE list is the only thing extracted; failure is logged.
CPU index page redesignFalls 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).

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

See the S3 Persistence Contract for the full reason taxonomy.