Design: ServiceNow KB Fetch Processor
Overview
Fetches ServiceNow PSIRT (Product Security Incident Response Team) advisories from the master “ServiceNow Common Vulnerabilities & Exposures (CVE) Security Advisories” KB landing page (KB1226057) plus per-advisory KB articles, parses CVE references and release-family fix matrices, stores per-advisory rows under source="servicenow", and (when --emit-crit) stages CRIT candidate envelopes for Now Platform / AI Platform / ITSM / CSM / HR / Security Operations / etc.
Source identifier: servicenow
Data type: fetch (HTML scraping; ServiceNow does not publish RSS, JSON API, or CSAF for advisories)
ECS task name: go-servicenow-kb-fetch-processor
Schedule: Runs weekly on Fridays at 06:00 UTC (cron(0 6 ? * FRI *)).
Pattern: A — inline per-record CRIT staging
Phase: 1.8
⚠ CRIT staging is inert in production.
--emit-critdefaults tofalse(main.go:56) and the ECS command interraform/go-schedules.tf:4065is["/app/servicenow-kb-fetch-processor"]with no flags, so every scheduled run takes theemitCrit == falsepath. Everything below under Service Mapping, CRIT Envelope Shape and theS3QueueObject/CritRecordsections only executes for a localjust go-servicenow-kb-fetch-backfill(whose recipe defaultsEMIT_CRIT=true).scripts/task-manager.tomlalso declaresemit-critdefaulttrue, which does not reach the container.
Data Source
| Item | Value |
|---|---|
| Master landing | https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB1226057 |
| Per-KB URL shape | https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB{N} |
| Format | Server-rendered HTML with embedded JSON-LD (<script type="application/ld+json">) |
| Authentication | None — anonymous fetch returns full content |
| Volume | ~25 advisories total (2017–present); ~10–15/year |
| License | Public — no robots.txt restrictions; respect ~500ms inter-request gap |
Landing-page shape
The master KB1226057 page is server-rendered (AngularJS portal shell with the article body inlined as JSON-LD plus inline anchors). The advisory table is baked into the initial HTML payload — no JS execution required.
<a ... href="/kb?id=kb_article_view&sysparm_article=KB2693566" ...>KB2693566</a>
URL escaping — ServiceNow renders & as & and = as = in some contexts. The extractor’s regex normalises both.
Per-KB article shape
<title>[Security Advisory] CVE-2026-0542 - Remote Code Execution in ServiceNow AI Platform - Security - Now Support Portal</title>
<script type="application/ld+json">
{"@type":"Article","datePublished":"2026-02-25","dateModified":"2026-02-25","articleBody":"..."}
</script>
<!-- body with affected release-family table:
Australia | Patch X | Date
Zurich | Patch Y Hotfix Zb | Date
Yokohama | Patch X | Date
-->
Release-family names ServiceNow uses (release vehicles):
Australia, Zurich, Yokohama, Xanadu, Washington, Vancouver, Utah, Tokyo, San Diego, Rome, Quebec, Paris, Orlando, New York, Madrid, London, Kingston, Jakarta.
Architecture / Data Flow
Source → Database Mappings
CVEMetadata (one row per advisory)
| HTML/JSON-LD field | CVEMetadata column | Notes |
|---|---|---|
KB ID (e.g. KB2693566) | cveId | Primary key, verbatim. ServiceNow uses opaque KB numbers — there is no SFDC-style ID scheme. |
"servicenow" | source | Constant. |
JSON-LD datePublished (else dateModified) | datePublished | Unix seconds (int4 per ALAS convention). |
<title> element | title | Format [Security Advisory] CVE-YYYY-NNNN - {description} - Security - Now Support Portal. |
"PUBLISHED" | state | Constant. |
"5.0" | dataVersion | CVE schema version. |
| KB article URL | sourceAdvisoryRef | Canonical advisory URL. |
sha1(KBID | publishedAt | sha256(rawHTML)[:8]) | sourceFileHash | Resume detection. |
CVEAlias
CVE-* and GHSA-* extracted from body via regex. Passed through db.InsertAliases(ctx, tx, kbID, "servicenow", aliases, logger) per AGENTS.md alias-write contract:
- The helper canonicalises edge direction — CVE IDs become
primaryCveIdand KB IDs becomealiasCveIdbecause CVEs are the canonical primary. - Empty alias list still calls
InsertAliasesso the same-cveId cross-source backfill runs on every store. - One edge per source-pair (cve.org, nist-nvd, vulncheck-nvd, circl, etc.) per the helper’s enumeration.
CVEMetadataReferences
Every URL extracted from body text via https?://[^\s<>"')]+ regex, after the deny-host filter. Reference type classification:
i==0 AND u==KB-URL→advisory- contains
cve.org/cve.mitre.org//security/advisories/ghsa-/nvd.nist.gov/vuln/detail/cve-→advisory - contains
/releases/tag///patch→patch - everything else →
web
CVEDescription
- Body text truncated to 1500 chars → one row with
containerType="cna",lang="en". - When the resolution paragraph (
<b>Resolution:</b>/<b>Mitigation:</b>/<b>What you should do:</b>) is present → second row prefixed with"Resolution: ".
S3 archive
Per-advisory payload written to:
servicenow/files/{sha256}/{kbID}.json
The archive contains the ParsedAdvisory struct serialised to JSON: title, body text, resolution text, CVE/GHSA lists, ref URLs, release families, CVSS score, published date.
S3QueueObject
One row per staged CRIT envelope with bucket / key / source="servicenow-kb-fetch-processor" / processingStatus. Lifecycle:
pending(afterRegisterS3QueueObject)inserted(after publisher upserts CritRecord and moves S3 object toapproved/inserted/)rejected(when revalidation fails)
Service Mapping
Bracketed product hints from title + body resolve to spec/extended dict tuples. Order matters — more-specific synonyms first.
| Body/title contains (case-insensitive) | Service | Resource type | Dict source |
|---|---|---|---|
| “now assist” / “ai platform” | ai_platform | instance | extended |
| “customer service management” | csm | case | spec |
| “hr service delivery” / “hrsd” | hr | case | spec |
| “security operations” / “secops” | security_ops | vulnerability | spec |
| “service catalog” | service_catalog | catalog_item | spec |
| “event management” | event_management | event | spec |
| “performance analytics” | performance_analytics | indicator | spec |
| “asset management” | asset_management | asset | spec |
| “configuration management database” | cmdb | configuration_item | spec |
| “governance risk” | grc | policy_statement | spec |
| “itsm” | itsm | incident | spec |
| “discovery” | discovery | discovery_status | spec |
| “devops” | devops | pipeline | spec |
| “knowledge management” | knowledge | article | spec |
| “app engine” | app_engine | application | extended |
| “flow designer” / “automation engine” | automation_engine | flow | extended |
| “now platform” | now_platform | instance | extended |
| Fallback when release family present but no product match | now_platform | instance | extended |
Extended-dict layer (internal/critutil/dictionaries/extended/servicenow.json): 4 entries — now_platform:instance, ai_platform:instance, app_engine:application, automation_engine:flow. All use template_format=servicenow_table_url (matches the schema enum) and region_behavior=global-only.
Business Rules
Extracted from code conditionals — every conditional is documented as a business rule.
Resume / re-fetch (main.go:advisoryHash, fetch loop)
| Rule | Code conditional | Rationale |
|---|---|---|
| R1 Re-fetch only when content changes | if !*force && resumeSet[pa.KBID] == hash { continue } | Hash combines KB ID + publishedAt + sha256(rawHTML)[:8]; advisory text changes → re-store. |
| R3 Skip when limit reached | if *limit > 0 && i >= *limit { break } | --limit counts landing entries, not stored rows, so a limited run may store fewer than --limit records. 0 = unlimited. |
| R4 Soft-deadline guard | if time.Now().After(softDeadline.Add(-5*time.Minute)) { break } | ECS-only safety; backfill mode (no EXPECTED_DURATION_MINUTES env) runs to completion. |
Master-KB filtering (parser.go:ParseLanding)
| Rule | Code conditional | Rationale |
|---|---|---|
| R5 Filter the master KB itself | seen := map[string]bool{"KB1226057": true} | KB1226057 is the landing page; it lists itself in its own anchor table. |
| R6 Dedup KB IDs | if seen[kb] { continue } | A KB may appear multiple times in the landing (table + breadcrumb + JSON-LD). Keep one. |
CVSS extraction (parser.go:ParseAdvisory)
| Rule | Code conditional | Rationale |
|---|---|---|
| R7 First numeric CVSS wins | if m := cvssRe.FindStringSubmatch(pa.BodyText); len(m) == 2 { ... } | A body may quote multiple CVSS scores (vendor + reporter); take the first. |
| R8 Severity derives from CVSS | severityFromCVSS(score) — see severity table | NIST FIRST-published thresholds: ≥9 Critical / ≥7 High / ≥4 Medium / >0 Low / 0 (empty). |
The extracted score and severity are consumed only by the CRIT confidence
ladder and fix_propagation inference. writeAdvisory (main.go:326) writes no
CVEMetric row and leaves CVEMetadata.vectorString, affectedVendor,
affectedProduct and rawDataJSON null. With CRIT staging off in ECS (see the
banner above), the CVSS extraction currently has no consumer in production.
Service mapping (service_map.go:Resolve)
| Rule | Code conditional | Rationale |
|---|---|---|
| R9 First synonym match per (service, resource_type) wins | for _, s := range snSynonyms { if strings.Contains(corpus, s.hint) { ... break } } | Order matters; ai platform must win over now platform for AI advisories. |
| R10 Release-family fallback to Now Platform | if len(matches) == 0 && len(releaseFamilies) > 0 { ... now_platform/instance } | Advisories that name only “Zurich Patch 5” without a service → still represent a platform-level fix. |
| R11 Skip CRIT staging when no service resolves | if len(matches) == 0 { logger.Info("crit skipped"); return nil } | CVEMetadata + references still land. The CRIT path is suppressed with a logged warning so we know which extended-dict entries to author next. |
fix_propagation inference (crit_mapper.go:inferFixPropagation)
| Body signal (lc) | vex_status | fix_propagation | existing_remain | Rule |
|---|---|---|---|---|
| “deployed to affected hosted instances” / “applied to servicenow-managed instances” / “automatically applied” / “no customer action” | fixed | automatic | false | R12 Hosted-fix language → provider-only. |
| “rotate” + (“credential” | “key” | “token” | “secret”) | fixed | credential_rotation | true | R13 Rotation language → credential rotation. |
| “configure”+“best practices” / “review the recommended setting” / “configuration change” | fixed | config_change | true | R14 Customer-config language. |
| “upgrade to”+“patch” / “apply the patch” / “apply the hotfix” / “patch”+“hotfix” | fixed | version_update | true | R15 Patch-application language. |
| Default when CVSS ≥ 7.0 | fixed | version_update | true | R16 High-severity bias toward customer action when text is silent. |
| Default otherwise | fixed | automatic | false | R17 Low-severity / informational bias toward provider-only. |
Confidence ladder (crit_mapper.go:confidenceFor)
| Rule | Conditions |
|---|---|
R18 low when no body aliases | !bodyHasAliases |
R19 high when text-pinned + non-zero CVSS | fromText && cvssScore > 0 (implicit: bodyHasAliases) |
R20 medium otherwise | (default branch) |
Detection emission (crit_mapper.go)
| Rule | Code conditional |
|---|---|
R21 Always emit pre_fix detection placeholder | hardcoded pending_reason="query_in_development". |
R22 Emit misconfiguration detection when fix requires opt-in | if helpers.DetectionMisconfigurationRequired(fixProp) { ... } — fires when fixProp ∈ {opt_in, config_change}. |
vex_status / shared_responsibility coupling (crit_mapper.go)
| Rule | Code |
|---|---|
R23 automatic ⇒ provider_only | sharedResponsibility(fp) returns provider_only iff fp == "automatic". |
R24 existing_deployments_remain_vulnerable = false iff provider_only + automatic | existingRemain(resp, fp) returns !(resp=="provider_only" && fp=="automatic"). |
Reference filtering (parser.go:isDenyHost)
| Rule | Pattern |
|---|---|
| R25 Drop analytics hosts | URL contains google-analytics.com or doubleclick.net. |
| R26 Drop ServiceNow asset bucket | URL contains servicenow.com/sites/default/files. |
| R27 Always include the article URL | urlSeen[pa.URL] = true before the body-URL loop. |
Drain (main.go:)
| Rule | Code |
|---|---|
| R28 Skip drain when env disable set | !critpublisher.DisabledByEnv() (reads CRIT_DISABLE_INPROCESS_DRAIN). |
| R29 Skip drain when no envelopes staged | len(stagedKeys) > 0. |
| R30 Drain workers fixed at 4 | Options{Workers: 4} — mirrors AWS / Salesforce. |
CRIT Envelope Shape
Per (vulnID × ServiceMatch):
{
"envelope_version": "2",
"spec_version": "CRITv0.2.0",
"producer": "servicenow-kb-fetch-processor/0.1.0",
"cve_id": "CVE-2026-0542",
"natural_key": {"provider": "servicenow", "service": "ai_platform", "resource_type": "instance"},
"candidate": {
"vulnerability_id": "CVE-2026-0542",
"provider": "servicenow",
"service": "ai_platform",
"resource_type": "instance",
"vector_string": "...",
"template": "https://{instance}.service-now.com/api/now/ai/{ai-component}",
"template_format": "servicenow_table_url",
"vex_status": "fixed",
"fix_propagation": "automatic",
"shared_responsibility": "provider_only",
"existing_deployments_remain_vulnerable": false,
"resource_lifecycle": "stateful_managed",
"temporal": {"vuln_published_date": "2026-02-25", "provider_fix_date": "2026-02-25", "service_available_date": "2023-09-20"},
"remediation_actions": [{"sequence": 1, "type": "provider_update", "title": "Provider applies fix automatically", "auto_remediable": true}],
"provider_advisory": {"advisory_id": "KB2693566", "advisory_url": "https://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB2693566"},
"detections": [{"provider": "servicenow", "service": "ai_platform", "detection_phase": "pre_fix", "pending_reason": "query_in_development"}]
},
"provenance": {
"primary_source": {"kind": "servicenow", "advisory_id": "KB2693566", "advisory_url": "...", "s3_key": "servicenow/files/{sha256}/KB2693566.json"},
"evidence": [
{"rule": "source-scoped-provider", "detail": "ServiceNow PSIRT KB advisory implies provider=servicenow"},
{"rule": "service-text-match", "detail": "body text matched \"ai platform\" ⇒ (servicenow,ai_platform,instance)"},
{"rule": "dictionary-resolved", "detail": "(servicenow,ai_platform,instance) ∈ extended dict"},
{"rule": "fix-propagation-source", "detail": "fix_propagation=automatic inferred from explicit body text"},
{"rule": "cve-publish-date-lookup", "detail": "loaded canonical datePublished=2026-02-25 for CVE-2026-0542 from CVEMetadata"}
],
"confidence": "high"
}
}
Verification Queries
-- Advisory count
SELECT count(*) FROM "CVEMetadata" WHERE source='servicenow';
-- Every advisory has ≥1 reference
SELECT count(*) FROM "CVEMetadataReferences"
WHERE source='servicenow'
AND "cveId" IN (SELECT "cveId" FROM "CVEMetadata" WHERE source='servicenow')
GROUP BY "cveId" HAVING count(*) = 0;
-- expected: 0 rows
-- CVE alias edges (canonical direction: CVE primary, KB alias)
SELECT count(*) FROM "CVEAlias"
WHERE "discoveredFrom"='servicenow' AND "primaryCveId" LIKE 'CVE-%';
-- In-process drain — pending state should be empty
SELECT "processingStatus", count(*)
FROM "S3QueueObject" WHERE source='servicenow-kb-fetch-processor'
GROUP BY 1;
-- expected: only `inserted` (and possibly `rejected`); never `pending`
-- Service distribution
SELECT service, count(*) FROM "CritRecord"
WHERE provider='servicenow' AND "critJSON"->'provider_advisory'->>'advisory_id' LIKE 'KB%'
GROUP BY 1 ORDER BY 2 DESC;
-- expected weighting: now_platform > ai_platform > devops/perf_analytics/etc
Risk Surface
| Risk | Guard |
|---|---|
KB1226057 falls behind a getCustomerSupport redirect | Verified anonymous-fetchable today; resume cache survives — re-discovery via the per-KB IDs already known. |
| JSON-LD article body changes shape | <table> parsing serves as secondary signal; CI golden-test fixtures (testdata/landing.html, testdata/kb-2693566.html) catch drift. |
| Missing extended-dict entry blocks CRIT staging | CVEMetadata row + references still land; CRIT skipped with logged warning naming the unmapped product. |
| ServiceNow renames a release family | Family list in parser.go is a constant slice; CI golden tests catch missing matches; non-blocking — now_platform fallback fires. |
template_format schema enum drift | Tests + publisher’s revalidation catch misuse at stage time; we use servicenow_table_url which is in the spec enum. |
S3 Persistence
- Archive path:
servicenow/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/servicenow-kb-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.