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-crit defaults to false (main.go:56) and the ECS command in terraform/go-schedules.tf:4065 is ["/app/servicenow-kb-fetch-processor"] with no flags, so every scheduled run takes the emitCrit == false path. Everything below under Service Mapping, CRIT Envelope Shape and the S3QueueObject / CritRecord sections only executes for a local just go-servicenow-kb-fetch-backfill (whose recipe defaults EMIT_CRIT=true). scripts/task-manager.toml also declares emit-crit default true, which does not reach the container.


Data Source

ItemValue
Master landinghttps://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB1226057
Per-KB URL shapehttps://support.servicenow.com/kb?id=kb_article_view&sysparm_article=KB{N}
FormatServer-rendered HTML with embedded JSON-LD (<script type="application/ld+json">)
AuthenticationNone — anonymous fetch returns full content
Volume~25 advisories total (2017–present); ~10–15/year
LicensePublic — 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 &amp; and = as &#61; 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

sequenceDiagram participant ECS as ECS Scheduler participant Proc as servicenow-kb-fetch-processor participant SN as support.servicenow.com participant DB as PostgreSQL participant S3 as S3 (vdb-manager-artifacts) ECS->>Proc: cron(0 6 ? * FRI *) Proc->>SN: GET KB1226057 (landing) SN-->>Proc: HTML (server-rendered, JSON-LD, KB anchors) Proc->>Proc: ParseLanding → []LandingEntry (KB IDs) Proc->>DB: LoadProcessedHashes(source=servicenow) DB-->>Proc: resumeSet[KB] -> hash loop per KB advisory (sequential, 500ms gap) Proc->>SN: GET /kb?sysparm_article=KB{N} SN-->>Proc: HTML body + JSON-LD Proc->>Proc: ParseAdvisory → ParsedAdvisory Proc->>Proc: hash = sha1(KB | publishedAt | sha256(raw)[:8]) alt hash unchanged AND not --force Proc->>Proc: skip (resume cache hit) else Proc->>DB: storeAdvisory (CVEMetadata + Aliases + References + Descriptions) Proc->>S3: archive {sha256}/{KB}.json opt --emit-crit AND service map resolves Proc->>Proc: stageCRIT (one envelope per vulnID × match) Proc->>S3: PUT crit-candidates/pending/... Proc->>DB: RegisterS3QueueObject(status=pending) end end end opt stagedKeys not empty AND CRIT_DISABLE_INPROCESS_DRAIN unset Proc->>Proc: critpublisher.DrainKeys (4 workers) loop per envelope Proc->>S3: GET pending/{key} Proc->>Proc: Revalidate (schema + vector + slot ABNF + spec rules) alt validation pass Proc->>DB: UPSERT CritRecord (newer-only WHERE) Proc->>S3: MoveObject pending → approved/inserted/ Proc->>DB: MarkS3QueueObjectInserted opt vex_status=fixed AND wrote=true Proc->>S3: PUT {key}.vex.json (CycloneDX 1.6) end else validation fail Proc->>S3: MoveObject pending → rejected/spec-violation/ Proc->>DB: MarkS3QueueObjectRejected(errorCause) end end end

Source → Database Mappings

CVEMetadata (one row per advisory)

HTML/JSON-LD fieldCVEMetadata columnNotes
KB ID (e.g. KB2693566)cveIdPrimary key, verbatim. ServiceNow uses opaque KB numbers — there is no SFDC-style ID scheme.
"servicenow"sourceConstant.
JSON-LD datePublished (else dateModified)datePublishedUnix seconds (int4 per ALAS convention).
<title> elementtitleFormat [Security Advisory] CVE-YYYY-NNNN - {description} - Security - Now Support Portal.
"PUBLISHED"stateConstant.
"5.0"dataVersionCVE schema version.
KB article URLsourceAdvisoryRefCanonical advisory URL.
sha1(KBID | publishedAt | sha256(rawHTML)[:8])sourceFileHashResume 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 primaryCveId and KB IDs become aliasCveId because CVEs are the canonical primary.
  • Empty alias list still calls InsertAliases so 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-URLadvisory
  • contains cve.org / cve.mitre.org / /security/advisories/ghsa- / nvd.nist.gov/vuln/detail/cve-advisory
  • contains /releases/tag/ / /patchpatch
  • 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 (after RegisterS3QueueObject)
  • inserted (after publisher upserts CritRecord and moves S3 object to approved/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)ServiceResource typeDict source
“now assist” / “ai platform”ai_platforminstanceextended
“customer service management”csmcasespec
“hr service delivery” / “hrsd”hrcasespec
“security operations” / “secops”security_opsvulnerabilityspec
“service catalog”service_catalogcatalog_itemspec
“event management”event_managementeventspec
“performance analytics”performance_analyticsindicatorspec
“asset management”asset_managementassetspec
“configuration management database”cmdbconfiguration_itemspec
“governance risk”grcpolicy_statementspec
“itsm”itsmincidentspec
“discovery”discoverydiscovery_statusspec
“devops”devopspipelinespec
“knowledge management”knowledgearticlespec
“app engine”app_engineapplicationextended
“flow designer” / “automation engine”automation_engineflowextended
“now platform”now_platforminstanceextended
Fallback when release family present but no product matchnow_platforminstanceextended

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)

RuleCode conditionalRationale
R1 Re-fetch only when content changesif !*force && resumeSet[pa.KBID] == hash { continue }Hash combines KB ID + publishedAt + sha256(rawHTML)[:8]; advisory text changes → re-store.
R3 Skip when limit reachedif *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 guardif 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)

RuleCode conditionalRationale
R5 Filter the master KB itselfseen := map[string]bool{"KB1226057": true}KB1226057 is the landing page; it lists itself in its own anchor table.
R6 Dedup KB IDsif seen[kb] { continue }A KB may appear multiple times in the landing (table + breadcrumb + JSON-LD). Keep one.

CVSS extraction (parser.go:ParseAdvisory)

RuleCode conditionalRationale
R7 First numeric CVSS winsif m := cvssRe.FindStringSubmatch(pa.BodyText); len(m) == 2 { ... }A body may quote multiple CVSS scores (vendor + reporter); take the first.
R8 Severity derives from CVSSseverityFromCVSS(score) — see severity tableNIST 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)

RuleCode conditionalRationale
R9 First synonym match per (service, resource_type) winsfor _, 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 Platformif 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 resolvesif 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_statusfix_propagationexisting_remainRule
“deployed to affected hosted instances” / “applied to servicenow-managed instances” / “automatically applied” / “no customer action”fixedautomaticfalseR12 Hosted-fix language → provider-only.
“rotate” + (“credential” | “key” | “token” | “secret”)fixedcredential_rotationtrueR13 Rotation language → credential rotation.
“configure”+“best practices” / “review the recommended setting” / “configuration change”fixedconfig_changetrueR14 Customer-config language.
“upgrade to”+“patch” / “apply the patch” / “apply the hotfix” / “patch”+“hotfix”fixedversion_updatetrueR15 Patch-application language.
Default when CVSS ≥ 7.0fixedversion_updatetrueR16 High-severity bias toward customer action when text is silent.
Default otherwisefixedautomaticfalseR17 Low-severity / informational bias toward provider-only.

Confidence ladder (crit_mapper.go:confidenceFor)

RuleConditions
R18 low when no body aliases!bodyHasAliases
R19 high when text-pinned + non-zero CVSSfromText && cvssScore > 0 (implicit: bodyHasAliases)
R20 medium otherwise(default branch)

Detection emission (crit_mapper.go)

RuleCode conditional
R21 Always emit pre_fix detection placeholderhardcoded pending_reason="query_in_development".
R22 Emit misconfiguration detection when fix requires opt-inif helpers.DetectionMisconfigurationRequired(fixProp) { ... } — fires when fixProp ∈ {opt_in, config_change}.

vex_status / shared_responsibility coupling (crit_mapper.go)

RuleCode
R23 automaticprovider_onlysharedResponsibility(fp) returns provider_only iff fp == "automatic".
R24 existing_deployments_remain_vulnerable = false iff provider_only + automaticexistingRemain(resp, fp) returns !(resp=="provider_only" && fp=="automatic").

Reference filtering (parser.go:isDenyHost)

RulePattern
R25 Drop analytics hostsURL contains google-analytics.com or doubleclick.net.
R26 Drop ServiceNow asset bucketURL contains servicenow.com/sites/default/files.
R27 Always include the article URLurlSeen[pa.URL] = true before the body-URL loop.

Drain (main.go:)

RuleCode
R28 Skip drain when env disable set!critpublisher.DisabledByEnv() (reads CRIT_DISABLE_INPROCESS_DRAIN).
R29 Skip drain when no envelopes stagedlen(stagedKeys) > 0.
R30 Drain workers fixed at 4Options{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

RiskGuard
KB1226057 falls behind a getCustomerSupport redirectVerified 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 stagingCVEMetadata row + references still land; CRIT skipped with logged warning naming the unmapped product.
ServiceNow renames a release familyFamily list in parser.go is a constant slice; CI golden tests catch missing matches; non-blocking — now_platform fallback fires.
template_format schema enum driftTests + 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).

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

See the S3 Persistence Contract for the full reason taxonomy.