Design: Salesforce Advisories RSS Processor
Overview
Fetches Salesforce security advisories from the master RSS feed at security.salesforce.com/security-advisories/rss, parses CVE/GHSA aliases and bracketed product lists from each item, stores per-advisory rows under source="salesforce", and (when --emit-crit) stages CRIT candidate envelopes per (vulnID × Salesforce product).
Help articles (help.salesforce.com / kb.tableau.com / help.mulesoft.com) are SPA-rendered and not anonymously parseable; the RSS <description> is the canonical body — no per-article HTML enrichment fetch is needed.
Source identifier: salesforce
Data type: rss
ECS task name: go-salesforce-advisories-rss-processor
Schedule: Runs weekly on Saturdays at 05:00 UTC (cron(0 5 ? * SAT *)).
Pattern: A — inline per-record CRIT staging
Phase: 1.7
Data Source
| Item | Value |
|---|---|
| Master RSS | https://security.salesforce.com/security-advisories/rss |
| Format | RSS 2.0 |
| Authentication | None |
| Volume | ~93 items (2017→present); ~6–28 advisories per year |
| License | Public |
RSS item shape
<item>
<title><![CDATA[[Vulnerability] CVE's for various vulnerabilities impacting some versions of Tableau Server and Tableau Desktop]]></title>
<link>https://help.salesforce.com/s/articleView?id=005132575&type=1</link>
<guid>https://help.salesforce.com/s/articleView?id=005132575&type=1</guid>
<pubDate>Fri, 22 Aug 2025 00:00:00 GMT</pubDate>
<description><![CDATA[[Tableau] We assigned the CVSSv3 score as 9.1. Affected CVE-2025-26496, CVE-2025-26497. We strongly encourage Tableau Server customers to apply the update. https://www.cve.org/CVERecord?id=CVE-2025-26496]]></description>
</item>
Article-ID extraction
Three URL shapes recur:
help.salesforce.com/s/articleView?id=NNNNNNNNN&type=1→NNNNNNNNN(numeric)status.salesforce.com/generalmessages/NNNNNNNNN→status-NNNNNNNNNkb.tableau.com/articles/Issue/{slug}→tableau-{slug}help.mulesoft.com/{slug}→mulesoft-{slug}- Anything else →
{host}-{lastPathSegment}
The article ID becomes CVEMetadata.cveId verbatim — no SFDC-YYYY-NNN scheme exists (precedent: AWS slug bulletins).
Architecture / Data Flow
Source → Database Mappings
CVEMetadata
| RSS field | Column | Notes |
|---|---|---|
ArticleID(<link>) | cveId | Verbatim. Numeric (005132575), status-*, tableau-*, or mulesoft-*. |
"salesforce" | source | Constant. |
<pubDate> (RFC 2822) | datePublished | Unix seconds. Falls back to time.Now() on parse failure. |
<title> (CDATA) | title | Bracketed prefix retained ([Vulnerability] ...). |
"PUBLISHED" | state | Constant. |
"5.0" | dataVersion | Constant. |
<link> | sourceAdvisoryRef | Article URL. |
sha1(articleID | pubDate | sha256(bodyText)[:8]) | sourceFileHash | Resume detection. |
CVEAlias
CVE-* and GHSA-* extracted from <title> + <description> via regex. Passed to db.InsertAliases. Empty list still triggers same-cveId backfill (per AGENTS.md alias contract).
GHSA canonicalisation: GHSA- prefix preserved in upper-case + body in lower-case (e.g. GHSA-5x4f-fvv8-wr65).
CVEMetadataReferences
- Always include the article URL as the first reference (advisory type).
- All
https?://...URLs from<description>extracted via regex. - Filtered against
isDenyHost(analytics + asset CDNs). - Deduped via
urlSeenmap.
Type classification:
- self-link →
advisory - contains
cve.org/cve.mitre.org//security/advisories/ghsa-/nvd.nist.gov/vuln/detail/cve-→advisory - contains
/releases/tag/→patch - everything else →
web
CVEDescription
Body text truncated to 1500 chars → one row with containerType="cna", lang="en".
S3 archive
salesforce/files/{sha256}/{articleID}.json
Service Mapping
service_map.go:Resolve walks the bracketed product list and resolves to spec/extended dict tuples.
Bracket extraction (rss.go:ExtractProducts + parser.go:filterCategoryLeaders)
The leading [..] of title or description contains either a category leader or a product list:
- Category leaders (filtered out):
Vulnerability,Security Advisory,Security Update,Security Notification,Security Enhancements,Tableau Security Advisory. - Product lists (kept):
[Tableau, Slack, Service Cloud, ...]comma-separated.
Resolution order (more-specific first):
| Bracket text contains (lc) | Service | Resource type | Dict |
|---|---|---|---|
| “tableau cloud” / “tableau online” | tableau | cloud-site | extended |
| “tableau server” / “tableau desktop” / “tableau prep” / “tableau” | tableau | server | extended |
| “marketing cloud account engagement” | pardot | account | extended |
| “marketing cloud” | marketing_cloud | account | extended |
| “pardot” | pardot | account | extended |
| “sales cloud” | sales_cloud | org | extended |
| “service cloud” | service_cloud | org | extended |
| “commerce cloud” / “b2c commerce” | commerce_cloud | store | extended |
| “experience cloud” / “community cloud” | experience_cloud | site | extended |
| “salesforce platform” / “force.com” | salesforce_platform | org | extended |
| “hyperforce” | hyperforce | instance | extended |
| “heroku” | heroku | app | extended |
| “mulesoft” / “anypoint” | mulesoft | environment | extended |
| “slack” | slack | workspace | extended |
| “quip” | quip | site | extended |
| “clicksoftware” | clicksoftware | instance | extended |
| “agentforce” | agentforce | agent | extended |
| “salesforce einstein” / “einstein” | einstein | copilot | extended |
Extended-dict layer (internal/critutil/dictionaries/extended/salesforce.json): 17 entries with template_format=salesforce_url. Spec dict salesforce.json covers SObject record-level entries (crm/account, tooling/apex_class) — disjoint from this layer.
Business Rules
Resume / re-fetch (main.go:entryHash)
| Rule | Conditional | Rationale |
|---|---|---|
| R1 Re-fetch only when content changes | if !*force && resumeSet[entry.ArticleID] == hash { continue } | Hash combines articleID + pubDate + sha256(bodyText)[:8]. |
| R2 Skip on missing article ID | if entry.ArticleID == "" { continue } | Logged + skipped. |
| R3 Skip when limit reached | if *limit > 0 && i >= *limit { break } | Smoke-test cap. |
| R4 Soft-deadline guard | if time.Now().After(softDeadline.Add(-5*time.Minute)) | ECS-only safety. |
RSS parsing (rss.go:ExtractProducts, parser.go:filterCategoryLeaders)
| Rule | Conditional | Rationale |
|---|---|---|
| R5 Description bracket wins over title bracket | pe.Products = filterCategoryLeaders(ExtractProducts(pe.BodyText)); if len(pe.Products) == 0 { ExtractProducts(pe.Title) } | Title bracket is typically a category leader ([Vulnerability]); description bracket holds the actual product list. |
| R6 Filter category leaders from product list | if categoryLeaders[lc] { continue } | Words like “Vulnerability” / “Security Advisory” must not be mistaken for products. |
CVSS extraction (parser.go:CombineRSS)
| Rule | Conditional |
|---|---|
| R7 First CVSS in body wins | cvssRe.FindStringSubmatch(pe.BodyText) (single match). |
| R8 Severity from CVSS | severityFromCVSS — same thresholds as ServiceNow (≥9 Critical / ≥7 High / ≥4 Medium / >0 Low). |
Service mapping (service_map.go:Resolve)
| Rule | Conditional |
|---|---|
| R9 First synonym match per (service, resource_type) wins | for _, s := range productSynonyms { ... break } — order matters for Tableau Cloud-vs-Server and Pardot-vs-MarketingCloud. |
| R10 Unmapped products surfaced | if !matched && lc != "" { unmappedProducts = append(...) } — logged so future extended dict entries can be added. |
| R11 Skip CRIT staging when no products resolve | if len(matches) == 0 { return nil } — CVEMetadata + references still land. |
fix_propagation inference (crit_mapper.go:inferFixPropagation)
| Body signal (lc) | vex_status | fix_propagation | Rule |
|---|---|---|---|
| “no action is required” / “no customer action” / “patch was applied to cloud instances” / “cloud customers do not need to take action” / “automatically updated” / “salesforce has applied” | fixed | automatic | R12 Hosted-fix language. |
| “rotate” + (“credential” | “key” | “token” | “secret”) | fixed | credential_rotation | R13 |
| “transition from” + (“tls” | “rsa” | “cipher”) | fixed | config_change | R14 Crypto transition. |
| “configure” + “best practices” / “configuration change” / “review the recommended configuration” | fixed | config_change | R15 |
| “we strongly encourage”+“update” / “we recommend”+“upgrade” / “users should upgrade” / “apply the update” / “apply the patch” | fixed | version_update | R16 |
| Default when CVSS ≥ 7.0 | fixed | version_update | R17 |
| Default otherwise | fixed | automatic | R18 |
Confidence ladder (crit_mapper.go:confidenceFor)
| Rule | Conditions |
|---|---|
R19 low when no body aliases | !bodyHasAliases — covers the ~89 of 93 informational notices without CVEs. |
R20 high when text-pinned + non-zero CVSS + body aliases | fromText && cvssScore > 0 (with bodyHasAliases true). |
R21 medium otherwise. |
Reference filtering (parser.go:isDenyHost)
| Rule | Pattern |
|---|---|
| R22 Drop AWS asset CDN | URL contains .awsstatic.com. |
| R23 Drop Adobe / Demdex analytics | URL contains amazonwebservicesinc.tt.omtrdc.net or .demdex.net. |
| R24 Drop generic analytics | URL contains google-analytics.com or doubleclick.net. |
R25 Always include the RSS <link> first | urlSeen[pe.URL] = true; pe.RefURLs = append(pe.RefURLs, pe.URL). |
vex_status / shared_responsibility coupling
Same as ServiceNow R23–R24:
- R26
automatic⇒provider_only. - R27
existing_deployments_remain_vulnerableisfalseiffprovider_only + automatic.
Drain (main.go)
- R28 Drain skipped when
CRIT_DISABLE_INPROCESS_DRAIN=true. - R29 Drain skipped when
len(stagedKeys) == 0. - R30 Drain workers fixed at 4.
Verification Queries
SELECT count(*) FROM "CVEMetadata" WHERE source='salesforce';
-- expected: ~91-93
-- Article-ID primary keys (no CVE/GHSA pattern)
SELECT count(*) FROM "CVEMetadata"
WHERE source='salesforce' AND "cveId" NOT LIKE 'CVE-%' AND "cveId" NOT LIKE 'GHSA-%';
-- expected: equal to row count
-- GHSA aliases extracted from body (recent advisories)
SELECT count(*) FROM "CVEAlias"
WHERE "discoveredFrom"='salesforce' AND "aliasCveId" LIKE 'GHSA-%';
-- expected: non-zero
-- Reference coverage
SELECT count(*) FROM "CVEMetadataReferences"
WHERE source='salesforce'
AND "cveId" IN (SELECT "cveId" FROM "CVEMetadata" WHERE source='salesforce')
GROUP BY "cveId" HAVING count(*) = 0;
-- expected: 0 rows
-- Service distribution (provider=salesforce)
SELECT service, count(*) FROM "CritRecord"
WHERE provider='salesforce' GROUP BY 1 ORDER BY 2 DESC;
-- expected weighting: tableau dominant, then mulesoft / salesforce_platform / heroku / etc
-- Pending should be empty
SELECT "processingStatus", count(*) FROM "S3QueueObject"
WHERE source='salesforce-advisories-rss-processor' GROUP BY 1;
-- expected: only `inserted`
Risk Surface
| Risk | Guard |
|---|---|
| Salesforce rotates the RSS endpoint | The /security-advisories/rss path has been stable since 2017; processor fails fast if it 404s. |
| help/kb article rendered SPA-only | We don’t fetch the article — the RSS description is canonical. |
| Advisory carries an unmapped product | CVEMetadata + references still land; CRIT skipped with unmappedProducts logged. |
| Spec dict’s record-level salesforce entries collide with extended product-level | Different (service, resource_type) keys → no collision. |
| GHSA canonicalisation drift | Tests in parser_test.go lock the form GHSA- + lowercase body. |
S3 Persistence
Not used. This processor does not currently archive payloads or quarantine failures to S3. Per the S3 Persistence Contract this is non-compliant — see the compliance matrix for the implementation roadmap.
⚠ Not in the compliance matrix — status needs verification.
Expected paths when implemented:
- Archive:
salesforce-advisories/files/{sha256}/{filename} - Quarantine:
failed-feeds/salesforce-advisories-rss-processor/{YYYY-MM-DD}/{reason}/{filename} - Likely reasons: (none documented)