Design: SAP Patch Day Fetch Processor

Overview

Scrapes the public SAP Security Patch Day archive pages on support.sap.com. Per-Note bodies (linked at me.sap.com/notes/...) are auth-walled (S-user login required), but each row in the support archive table carries enough information to write a useful row: note ID, primary CVE, additional CVE list, title, product, version list, severity label, CVSS score.

Source identifier: sap Data type: fetch (HTML scraping; no public RSS / JSON / CSAF) ECS task name: go-sap-patch-day-fetch-processor ECS schedule: Every Tuesday 16:00 UTC (cron(0 16 ? * TUE *)) — picks up Patch Tuesday after publication Pattern: A — inline per-record CRIT staging Phase: 1.9

Two operating modes

FlagModeURLs fetchedUsed by
--mode=current (default)Current month only{currentMonth}-{currentYear}.htmlECS daily/weekly task
--mode=backfillFull archive + current yearbulletin-{archiveStartYear..currentYear-1}.html + january-{currentYear}.html{currentMonth}-{currentYear}.htmljust go-sap-patch-day-fetch-backfill

archiveStartYear = 2021 — earliest year SAP exposes via the public archive URL pattern.

current 404s gracefully when the month’s page hasn’t been published yet (e.g. fetching may-2026.html on May 6, before that month’s Patch Tuesday). The processor logs “page not found, skipping” and continues.


Data Source

URLs

PatternExampleUse
bulletin-{YYYY}.htmlhttps://support.sap.com/en/my-support/knowledge-base/security-notes-news/bulletin-2025.htmlYearly archive — drives backfill for 2021..lastYear. ~120-300 notes per yearly page.
{month}-{YYYY}.htmlhttps://support.sap.com/en/my-support/knowledge-base/security-notes-news/april-2026.htmlMonthly Patch Day — drives both backfill (current year) and ECS daily run. ~6-30 notes per page.

Per-row HTML shape

<tr>
  <td width="10%"><p><a href="https://me.sap.com/notes/3719353">3719353</a></p></td>
  <td width="70%">
    <p>[<a href="https://www.cve.org/CVERecord?id=CVE-2026-27681">CVE-2026-27681</a>] <b>SQL Injection vulnerability in SAP Business Planning and Consolidation and SAP Business Warehouse</b></p>
    <p><u>Product</u> - SAP Business Planning and Consolidation and SAP Business Warehouse<br />
    Version(s) - HANABPC 810, BPC4HANA 300, SAP_BW 750, 752, 753, 754, 755, 756, 757, 758, 816</p>
  </td>
  <td width="10%"><p>Critical</p></td>
  <td width="7%"><p><a href="https://www.first.org/cvss/calculator/3.0#CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H">9.9</a></p></td>
</tr>

The </u> is followed by a non-breaking space ( ) before the dash separator — the parser normalises NBSP to regular space at fetch time.

Multi-CVE rows

<p><u>Additional CVE</u> - <a href="https://www.cve.org/CVERecord?id=CVE-2026-0496">CVE-2026-0496</a>, <a href="https://www.cve.org/CVERecord?id=CVE-2026-0495">CVE-2026-0495</a></p>

Extracted into note.AdditionalCVEs.

Malicious-package rows (CVE-less)

<p><b>Malicious open-source packages in SAP Cloud Application Programming Model & MTA Build Tool</b></p>
<p><u>Product</u> - SAP Cloud Application Programming Model & MTA Build Tool<br />
Package versions: cap-js/sqlite - v2.2.2, ...</p>

PrimaryCVE is empty; Versions captures the package list. Stored under cveId = SAP-{noteID} with no CVE alias.


Architecture / Data Flow

sequenceDiagram participant ECS participant Proc as sap-patch-day-fetch-processor participant SAP as support.sap.com participant DB as PostgreSQL participant S3 ECS->>Proc: cron(0 16 ? * TUE *) --mode=current Note over Proc: justfile invokes with --mode=backfill Proc->>Proc: PlanURLs(mode, now) alt mode=current Note over Proc: 1 URL — {currentMonth}-{currentYear}.html else mode=backfill Note over Proc: bulletin-{2021..currentYear-1}.html
+ january..{currentMonth}-{currentYear}.html end Proc->>DB: LoadProcessedHashes(source=sap) loop per URL (sequential, 750ms gap) Proc->>SAP: GET URL alt 404 SAP-->>Proc: not found Proc->>Proc: log "page not found, skipping" else 200 SAP-->>Proc: HTML page Proc->>Proc: NBSP normalise + ParsePage → []ParsedNote loop per note row Proc->>Proc: noteHash → resume cache lookup alt unchanged AND not --force Proc->>Proc: skip else Proc->>DB: storeNote (CVEMetadata + Aliases + References + Description) Proc->>S3: archive {sha256}/{SAP-noteID}.json opt --emit-crit AND product resolves Proc->>Proc: stageCRIT (one envelope per vulnID) Proc->>S3: PUT crit-candidates/pending/... Proc->>DB: RegisterS3QueueObject end end end end end Proc->>Proc: critpublisher.DrainKeys (4 workers)

Source → Database Mappings

CVEMetadata

Source fieldColumnNotes
SAP-{noteID}cveIdVerbatim SAP- prefix + numeric note ID. Distinct from CVE-* and from any other producer.
"sap"sourceConstant.
secondTuesday(year, month) from page URL (yearly archives stamp Jan 1 placeholder)datePublishedUnix seconds. SAP Patch Day is the 2nd Tue of each month.
Row’s <b>Title</b>title
"PUBLISHED"stateConstant.
"5.0"dataVersionConstant.
https://me.sap.com/notes/{noteID}sourceAdvisoryRefAuth-walled URL but stable.
sha256(noteID | title | product | versions | severity | cvss | cves)[:8] (hex)sourceFileHashResume detection — hash captures the row’s content.

CVEAlias

Primary CVE + every Additional CVE entry → db.InsertAliases(ctx, tx, "SAP-{noteID}", "sap", aliases, logger). Empty alias list still triggers the same-cveId backfill per AGENTS.md alias-write contract.

InsertAliases canonicalises the edge direction — CVE-* IDs become primaryCveId, SAP-{noteID} becomes aliasCveId.

CVEMetadataReferences

Always inserted:

  1. https://me.sap.com/notes/{noteID} (advisory; SAP-Note canonical link, with title)
  2. support.sap.com/.../{month/bulletin}-{year}.html (advisory; the page URL the row was scraped from)
  3. https://www.cve.org/CVERecord?id={primaryCVE} (advisory) when primary CVE present
  4. https://www.cve.org/CVERecord?id={cve} (advisory) — one per Additional CVE

CVEDescription

Single row, joining title + product + versions + severity/CVSS:

SQL Injection vulnerability in SAP Business Planning...
Product: SAP Business Planning and Consolidation and SAP Business Warehouse
Version(s): HANABPC 810, BPC4HANA 300, SAP_BW 750, 752, 753, ...
Severity: Critical (CVSS 9.9)

Columns and tables this producer does NOT write

The parser extracts more than the writer persists. These are gaps, not design:

Parsed fieldWould map toCurrent state
note.CVSSScore, note.SeverityCVEMetric row + CVEMetadata.vectorStringNot written. Both only reach the CVEDescription prose (main.go:427). Every source='sap' row has vectorString IS NULL and no metric row, even though the archive table publishes a numeric base score per Note.
note.Product, note.VersionsCVEMetadata.affectedVendor / affectedProduct, CVEAffectedNot written (main.go:363).
the ParsedNote payloadCVEMetadata.rawDataJSONNot written — the payload is marshalled for the S3 archive (main.go:200) but never stored on the row.
internal/aienrich passesNot wired. This producer never constructs an enricher, so CWE inference / ATT&CK mapping / TreeSitter never fire for sap.

S3 archive

sap/files/{sha256}/SAP-{noteID}.json

Carries the full ParsedNote struct as JSON for downstream re-processing.


Service Mapping

service_map.go:Resolve walks the row’s Product text and returns the first matching (service, resource_type) tuple.

Synonym table (initial)

Product text contains (lc)ServiceResource typeDict
“s/4hana cloud” / “s4hana cloud”s4hanacloud-tenantextended
“s/4hana” / “s4hana” / “s/4 hana”s4hanaon-premiseextended
“sap hana database” / “hana xs” / “hana extended application services”hana_databaseinstanceextended
“businessobjects” / “business intelligence platform”business_objectsserverextended
“netweaver application server abap” / “netweaver as abap” / “netweaver application server java” / “sap netweaver”netweaverapplication-serverextended
“abap platform”abap_platformsystemextended
“sap commerce” / “hybris”commerce_cloudstorefrontextended
“sap business one”business_oneinstallextended
“successfactors”successfactorstenantextended
“concur”concurtenantextended
“ariba”aribatenantextended
“fieldglass”fieldglasstenantextended
“customer data cloud” / “gigya”customer_data_cloudsiteextended
“signavio”signavioworkspaceextended
“solution manager”solution_managerinstallextended
“sap gateway”gatewayinstallextended
“btp” / “business technology platform” / “cloud foundry” / “sap cloud application programming model”btpsubaccountspec
Best-effort fall-throughs for SAP ERP / Business Warehouse / Landscape Transformation / Fiori App / Business Analytics / Content Management / Wily Introscope / RFCSDK / Application Server for ABAPmapped to nearest spec/extended entry

Extended-dict layer (internal/critutil/dictionaries/extended/sap.json): 16 entries — netweaver, abap_platform, s4hana (cloud-tenant + on-premise), business_one, commerce_cloud, successfactors, concur, ariba, fieldglass, customer_data_cloud, signavio, solution_manager, business_objects, hana_database, gateway. Templates use template_format ∈ {sap_btp_url, sap_odata_url, sap_sf_url} — all from the spec’s allowed enum.

The spec dict’s sap.json (55 entries / 12 services) covers SObject-record-level resources (S/4HANA company codes, SuccessFactors employees, BTP subaccounts) — disjoint from this product-cloud layer.

When the product text matches no synonym, the producer logs "crit skipped: unmapped product" with the product string. CVEMetadata + references + description still land. Operators can grow the synonym table by inspecting these warnings.


Business Rules

URL planning (fetch.go:PlanURLs)

RuleConditionalRationale
R1 Mode is “current” or “backfill”if *mode != "current" && *mode != "backfill" { os.Exit(1) }Hard validation — no implicit defaults from typos.
R2 current returns one URLif mode == "current" { return [monthlyURL(currentMonthName, currentYear)] }ECS daily — cheap; 404 on first-of-month unpublished pages.
R3 backfill walks 2021..currentYear-1 yearly + january..currentMonth current-year monthlyfor y := archiveStartYear; y < currentYear; y++ { append yearlyURL }; for m := 1; m <= int(currentMonth); m++ { append monthlyURL }Yearly archives have all the historical rows; current year needs monthly granularity because the yearly archive for the current year only updates after year-end.
R4 404 is graceful — skip the URLif fetchErr == errPageNotFound { logger.Info("page not found"); continue }Page-not-yet-published case (e.g. running on day 1 of the month before Patch Tuesday).

NBSP normalisation (parser.go:ParsePage)

RuleConditionalRationale
R5 Replace U+00A0 (NBSP) with regular space at fetch timedoc := strings.ReplaceAll(string(raw), " ", " ")SAP’s HTML uses NBSP between </u> and -; Go’s \s doesn’t include NBSP by default. Normalising before regex fixes the silent extraction-failure mode I hit during implementation.

Row extraction (parser.go:ParsePage)

RuleConditional
R6 Skip header rows (no me.sap.com/notes/ link)if len(idMatch) != 2 { continue }
R7 Dedup by note ID within a pageif seen[noteID] { continue }
R8 Primary CVE = first CVE link in the title cell up to <b>titleZone := row[:strings.Index(row, "<b>")]; primaryCVERe.FindStringSubmatch(titleZone) — restricts the search to the title cell so Additional-CVE links don’t shadow the primary.
R9 Title = first <b>...</b> in the rowtitleRe.FindStringSubmatch(row)
R10 Severity = trim of third <td> body texttds[2][1] (1-indexed cell, 0-indexed slice).
R11 CVSS = anchor-text inside fourth <td>; 0.0 acceptabletds[3][1] — handles malicious-package notes where CVSS is plain “0.0” not in an anchor.

Hash + resume (main.go:noteHash)

RuleConditional
R12 Hash inputs = noteID + title + product + versions + severity + cvss + primaryCVE + additionalCVEssha256 over the concatenation, truncated to 8 bytes hex.
R13 Re-fetch only on hash changeif !*force && resumeSet[note.NoteID] == hash { totalSkipped++; continue }
R14 Soft deadlinesoftDeadline = now + (EXPECTED_DURATION_MINUTES - 10min) (main.go:96), and the URL loop stops a further 5 min before it (main.go:169) — an effective 15-minute reserve on the 30-minute budget. EXPECTED_DURATION_MINUTES is unset by the justfile recipe, so backfill runs to completion.

CRIT mapper (crit_mapper.go:mapSAPNoteToCRIT)

RuleConditional
R15 Reject empty vulnIDif vulnID == "" { return ok=false }
R16 Prefer canonical CVE pubdateif lookup != nil && strings.HasPrefix(vulnID, "CVE-") { canonicalDate, found = lookup(vulnID) }
R17 ServiceAvailableDate fallback to NetWeaver/R/3 era (1996-01-01)if !serviceavail.Found("sap", svc) { saDate = "1996-01-01" }
R18 Reject when dictionary entry missingif template == "" { return ok=false } — surfaces unauthored extended-dict entries.
R19 S/4HANA cloud-tenant override forces fix_propagation=automaticif match.Service == "s4hana" && match.ResourceType == "cloud-tenant" { fixProp = "automatic" } — service-level switch can’t disambiguate cloud vs on-premise alone.

fix_propagation inference (crit_mapper.go:inferFixPropagation)

Without auth-walled body content, this is heuristic — driven by service shape + severity rather than text patterns.

RuleService-shape branchfix_propagation
R20 Cloud SaaS biassuccessfactors / concur / ariba / fieldglass / customer_data_cloud / signavio / btp / commerce_cloudautomatic (SAP applies tenant patches transparently).
R21 On-prem / customer-managed biasnetweaver / abap_platform / business_one / business_objects / hana_database / solution_manager / gatewayversion_update (customer must apply Note).
R22 Fall-through severity biascvssScore >= 9.0 || severity == "Critical"version_update
R23 Informational fall-throughseverity == "Informational"automatic
R24 Default(else)version_update

Confidence ladder (crit_mapper.go:confidenceFor)

Auth-walled body means we can’t text-match remediation language. Confidence ladder reflects this:

RuleConditions
R25 low when no body aliases!bodyHasAliases (vulnID falls back to SAP-{noteID}).
R26 medium ceiling — even with CVE + spec dict + high CVSScvssScore >= 7.0 && dictSource == "spec" → medium (never high for SAP — operators see "auth-walled body unavailable" in the fix-propagation evidence string).
R27 low otherwise.

Coupling rules (shared with Phase 1.x producers)

  • R28 automaticprovider_only.
  • R29 existing_deployments_remain_vulnerable = !(provider_only && automatic).

Drain (main.go)

  • R30 Drain skipped on CRIT_DISABLE_INPROCESS_DRAIN.
  • R31 4 workers.

Quarantine (main.go:storeNote)

  • R32 Failed transaction → quarantine the payload | if !storeNote(...) { uploader.QuarantineRecord(ctx, "store-error", payload); continue }.
  • R33 Archive only on success | if storeNote ok { uploader.ArchiveRecord(...) } — failed stores never appear in sap/files/.

Verification Queries

-- Note count
SELECT count(*) FROM "CVEMetadata" WHERE source='sap';
-- expected (after backfill): ≥400 (covers 2021..present, ~80-300 notes/year)

-- SAP-* primary keys
SELECT count(*) FROM "CVEMetadata"
 WHERE source='sap' AND "cveId" LIKE 'SAP-%';

-- CVE alias edges
SELECT count(*) FROM "CVEAlias" WHERE "discoveredFrom"='sap';
-- expected: high — every CVE'd note produces 1+ edges per source-pair

-- Reference coverage (every row has ≥1)
SELECT count(*) FROM "CVEMetadataReferences"
 WHERE source='sap'
   AND "cveId" IN (SELECT "cveId" FROM "CVEMetadata" WHERE source='sap')
 GROUP BY "cveId" HAVING count(*) = 0;
-- expected: 0 rows

-- Pending should be empty
SELECT "processingStatus", count(*) FROM "S3QueueObject"
 WHERE source='sap-patch-day-fetch-processor' GROUP BY 1;
-- expected: only `inserted`

-- Service distribution — historical SAP Patch Day pattern
SELECT service, count(*) FROM "CritRecord"
 WHERE provider='sap' GROUP BY 1 ORDER BY 2 DESC;
-- expected weighting: netweaver dominant, then s4hana, business_objects,
-- commerce_cloud, business_one, solution_manager, hana_database

Risk Surface

RiskGuard
SAP changes the archive URL patternHard-fails fast; ECS alarms via the notifier’s RecordError path. The bulletin-{year}.html and {month}-{year}.html patterns have been stable since 2018.
SAP changes the table HTML shapeTests fixture against captured HTML (testdata/april-2026.html, testdata/bulletin-2025.html); CI golden tests catch breakage.
NBSP / unicode whitespace driftR5 normalises NBSP at parse time; the test suite locks it in via TestParsePage_April2026_FirstRowFields (which expects non-empty Product).
Auth-walled body remains opaqueConfidence ladder bottoms at medium even for high-CVSS spec-dict matches; evidence string tells operators why. R26 documents the ceiling.
current mode fetches an unpublished month404 → skip + log "page not found"; non-fatal.
New SAP product without synonym entryLogged "crit skipped: unmapped product"; operator grows the synonym table. CVEMetadata + references + description still land.
Yearly archive lacks per-row Patch-Day datePageDate("bulletin-2021.html") returns Jan 1 placeholder; close-enough for the datePublished int4 column. Per-month accuracy comes from the monthly pages — backfill walks BOTH the yearly archive AND the current-year monthlies.
Spec/extended dict template_format outside the schema enumEnforced at validation time (ValidateRecord); tests cover the dict-load path. Templates here use sap_btp_url, sap_odata_url, sap_sf_url — all in the schema enum.
SAP publishes CSAF in the futureThis processor stays viable; a future sap-patch-day-csaf-processor would supersede it. Migration is non-blocking.

S3 Persistence

  • Archive path: sap/files/{sha256}/{filename}
  • Quarantine path: failed-feeds/sap-patch-day-fetch-processor/{YYYY-MM-DD}/{reason}/{filename}
  • Failure reasons emitted: 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[sap-patch-day-fetch-processor] PROC -->|success| ARCHIVE[("S3: sap/files/{sha256}/{filename}")] PROC -->|failure| Q[("S3: failed-feeds/sap-patch-day-fetch-processor/{date}/{reason}/{filename}")] PROC --> DB[(PostgreSQL)]

See the S3 Persistence Contract for the full reason taxonomy.