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
| Flag | Mode | URLs fetched | Used by |
|---|---|---|---|
--mode=current (default) | Current month only | {currentMonth}-{currentYear}.html | ECS daily/weekly task |
--mode=backfill | Full archive + current year | bulletin-{archiveStartYear..currentYear-1}.html + january-{currentYear}.html … {currentMonth}-{currentYear}.html | just 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
| Pattern | Example | Use |
|---|---|---|
bulletin-{YYYY}.html | https://support.sap.com/en/my-support/knowledge-base/security-notes-news/bulletin-2025.html | Yearly archive — drives backfill for 2021..lastYear. ~120-300 notes per yearly page. |
{month}-{YYYY}.html | https://support.sap.com/en/my-support/knowledge-base/security-notes-news/april-2026.html | Monthly 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
+ 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 field | Column | Notes |
|---|---|---|
SAP-{noteID} | cveId | Verbatim SAP- prefix + numeric note ID. Distinct from CVE-* and from any other producer. |
"sap" | source | Constant. |
secondTuesday(year, month) from page URL (yearly archives stamp Jan 1 placeholder) | datePublished | Unix seconds. SAP Patch Day is the 2nd Tue of each month. |
Row’s <b>Title</b> | title | |
"PUBLISHED" | state | Constant. |
"5.0" | dataVersion | Constant. |
https://me.sap.com/notes/{noteID} | sourceAdvisoryRef | Auth-walled URL but stable. |
sha256(noteID | title | product | versions | severity | cvss | cves)[:8] (hex) | sourceFileHash | Resume 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:
https://me.sap.com/notes/{noteID}(advisory; SAP-Note canonical link, with title)support.sap.com/.../{month/bulletin}-{year}.html(advisory; the page URL the row was scraped from)https://www.cve.org/CVERecord?id={primaryCVE}(advisory) when primary CVE presenthttps://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 field | Would map to | Current state |
|---|---|---|
note.CVSSScore, note.Severity | CVEMetric row + CVEMetadata.vectorString | Not 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.Versions | CVEMetadata.affectedVendor / affectedProduct, CVEAffected | Not written (main.go:363). |
the ParsedNote payload | CVEMetadata.rawDataJSON | Not written — the payload is marshalled for the S3 archive (main.go:200) but never stored on the row. |
| — | internal/aienrich passes | Not 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) | Service | Resource type | Dict |
|---|---|---|---|
| “s/4hana cloud” / “s4hana cloud” | s4hana | cloud-tenant | extended |
| “s/4hana” / “s4hana” / “s/4 hana” | s4hana | on-premise | extended |
| “sap hana database” / “hana xs” / “hana extended application services” | hana_database | instance | extended |
| “businessobjects” / “business intelligence platform” | business_objects | server | extended |
| “netweaver application server abap” / “netweaver as abap” / “netweaver application server java” / “sap netweaver” | netweaver | application-server | extended |
| “abap platform” | abap_platform | system | extended |
| “sap commerce” / “hybris” | commerce_cloud | storefront | extended |
| “sap business one” | business_one | install | extended |
| “successfactors” | successfactors | tenant | extended |
| “concur” | concur | tenant | extended |
| “ariba” | ariba | tenant | extended |
| “fieldglass” | fieldglass | tenant | extended |
| “customer data cloud” / “gigya” | customer_data_cloud | site | extended |
| “signavio” | signavio | workspace | extended |
| “solution manager” | solution_manager | install | extended |
| “sap gateway” | gateway | install | extended |
| “btp” / “business technology platform” / “cloud foundry” / “sap cloud application programming model” | btp | subaccount | spec |
| Best-effort fall-throughs for SAP ERP / Business Warehouse / Landscape Transformation / Fiori App / Business Analytics / Content Management / Wily Introscope / RFCSDK / Application Server for ABAP | mapped 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)
| Rule | Conditional | Rationale |
|---|---|---|
| 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 URL | if 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 monthly | for 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 URL | if 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)
| Rule | Conditional | Rationale |
|---|---|---|
| R5 Replace U+00A0 (NBSP) with regular space at fetch time | doc := 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)
| Rule | Conditional |
|---|---|
R6 Skip header rows (no me.sap.com/notes/ link) | if len(idMatch) != 2 { continue } |
| R7 Dedup by note ID within a page | if 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 row | titleRe.FindStringSubmatch(row) |
R10 Severity = trim of third <td> body text | tds[2][1] (1-indexed cell, 0-indexed slice). |
R11 CVSS = anchor-text inside fourth <td>; 0.0 acceptable | tds[3][1] — handles malicious-package notes where CVSS is plain “0.0” not in an anchor. |
Hash + resume (main.go:noteHash)
| Rule | Conditional |
|---|---|
| R12 Hash inputs = noteID + title + product + versions + severity + cvss + primaryCVE + additionalCVEs | sha256 over the concatenation, truncated to 8 bytes hex. |
| R13 Re-fetch only on hash change | if !*force && resumeSet[note.NoteID] == hash { totalSkipped++; continue } |
| R14 Soft deadline | softDeadline = 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)
| Rule | Conditional |
|---|---|
| R15 Reject empty vulnID | if vulnID == "" { return ok=false } |
| R16 Prefer canonical CVE pubdate | if 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 missing | if template == "" { return ok=false } — surfaces unauthored extended-dict entries. |
| R19 S/4HANA cloud-tenant override forces fix_propagation=automatic | if 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.
| Rule | Service-shape branch | fix_propagation |
|---|---|---|
| R20 Cloud SaaS bias | successfactors / concur / ariba / fieldglass / customer_data_cloud / signavio / btp / commerce_cloud | automatic (SAP applies tenant patches transparently). |
| R21 On-prem / customer-managed bias | netweaver / abap_platform / business_one / business_objects / hana_database / solution_manager / gateway | version_update (customer must apply Note). |
| R22 Fall-through severity bias | cvssScore >= 9.0 || severity == "Critical" | version_update |
| R23 Informational fall-through | severity == "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:
| Rule | Conditions |
|---|---|
R25 low when no body aliases | !bodyHasAliases (vulnID falls back to SAP-{noteID}). |
R26 medium ceiling — even with CVE + spec dict + high CVSS | cvssScore >= 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
automatic⇒provider_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 insap/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
| Risk | Guard |
|---|---|
| SAP changes the archive URL pattern | Hard-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 shape | Tests fixture against captured HTML (testdata/april-2026.html, testdata/bulletin-2025.html); CI golden tests catch breakage. |
| NBSP / unicode whitespace drift | R5 normalises NBSP at parse time; the test suite locks it in via TestParsePage_April2026_FirstRowFields (which expects non-empty Product). |
| Auth-walled body remains opaque | Confidence 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 month | 404 → skip + log "page not found"; non-fatal. |
| New SAP product without synonym entry | Logged "crit skipped: unmapped product"; operator grows the synonym table. CVEMetadata + references + description still land. |
| Yearly archive lacks per-row Patch-Day date | PageDate("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 enum | Enforced 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 future | This 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).
See the S3 Persistence Contract for the full reason taxonomy.