ovhcloud-json-processor

Status: Live Source: OVHcloud Statuspage.io sub-instances at {slug}.status-ovhcloud.com/api/v2/incidents.json Type: json (commentary enricher — no primary CVEMetadata writes) Source slug: ovhcloud Schedule: Runs weekly on Thursdays at 06:00 UTC (cron(0 6 ? * THU *)).

Overview

OVHcloud publishes no PSIRT advisory feed (no CSAF, no CVRF, no RSS — cert.ovh.com redirects to a CSIRT contact page). Their public Statuspage instances, however, emit CVE-titled incident notifications for upstream CVEs affecting managed products (cPanel, Managed Kubernetes, ESXi-based dedicated servers, Public Cloud compute and storage). Six sub-statuspages — bare-metal-servers, customer-service, hosted-private-cloud, network, public-cloud, web-cloud — each expose the standard Atlassian Statuspage v2 REST endpoint at /api/v2/incidents.json.

The processor is a commentary enricher: it does not create any CVEMetadata rows. For every CVE found in an incident’s title or update bodies, it walks every existing (cveId, source) pair in CVEMetadata and inserts a CVEMetadataReferences row pointing at the incident shortlink. CVEs not already known to vdb-manager are skipped (counted as cvesUnknown) — an OVHcloud incident is too weak a signal to seed a new primary CVE row on its own.

Source

PropertyValue
Endpoint shapehttps://{slug}.status-ovhcloud.com/api/v2/incidents.json
Sub-instancesbare-metal-servers, customer-service, hosted-private-cloud, network, public-cloud, web-cloud
AuthNone — public Statuspage.io REST
FormatJSON; page, incidents[] (50 per page), incident_updates[] per incident
Pagination?page=N query param; iterate until response carries zero incidents
Anti-botNone — standard Atlassian Statuspage product
CadenceWeekly run on Thursdays at 06:00 UTC; volume ~10–30 CVE-mentioning incidents per year across all regions
Identifier shapeNo OVH-issued ID minted; the source-of-truth identifier is the upstream CVE in the title or update body

Page Structure

Each /api/v2/incidents.json page response carries:

{
  "page": { "id": "...", "name": "...", "url": "...", "updated_at": "..." },
  "incidents": [
    {
      "id": "abc123",
      "name": "cPanel - CVE-2026-41940 - Incident Notification",
      "status": "resolved",
      "shortlink": "https://stspg.io/...",
      "created_at": "...",
      "updated_at": "...",
      "resolved_at": "...",
      "incident_updates": [
        { "id": "...", "status": "resolved", "body": "..." }
      ]
    }
  ]
}

CVE identifiers appear in either incident.name (rare) or in incident_updates[*].body (common — OVHcloud often files an incident first and edits the CVE reference in afterwards).

Parsing

internal/ovhcloud/parse.go provides two helpers:

HelperPurpose
ParsePage(b []byte) (*Page, error)Decode one /api/v2/incidents.json response; schema-tolerant — only the fields actually consumed are modelled
ExtractCVEs(inc Incident) []stringRun regexp.MustCompile(CVE-[0-9]{4}-[0-9]{4,7}) over incident.Name concatenated with every incident_updates[*].Body; dedup and sort upper-case

ExtractCVEs returns a non-nil empty slice when no CVE token matches so the caller can rely on len(cves) == 0 as the “skip this incident” signal.

Storage

No transaction wrapper or processor.StoreCVESourceData — references are inserted directly via per-row INSERT … ON CONFLICT DO NOTHING so re-runs are free and order-independent.

TableRows insertedFields
CVEMetadataReferencesOne per (cveId, parentSource) pair × incidentcveId, source = parentSource, url = incident.shortlink, type = "vendor", referenceSource = "ovhcloud", title = "[{region}] {incident.name}"
BulkDataDumpTrackerOne row keyed by source = "ovhcloud"sha256 = "", totalCVEs = refsInserted, frequency = 86400

The “fan-out across all parent sources” pattern mirrors cmd/fastly-fetch-processor/main.go:loadAllSourcesForCVEs — the OVHcloud incident is a fact about the CVE itself, not about any one ingesting source’s representation, so every (cveId, source) row in CVEMetadata gets its own reference edge.

Identifier Policy

No OVH-issued identifier is minted. The composite tracker key for content-addressing is (region, incident.id); the same upstream CVE may open separate incidents on multiple sub-statuspages and each one produces its own reference row.

Incremental Strategy

Tracker-hash gating with hard-coded daily frequency:

  1. Tracker freshness gate — at startup, db.GetTracker(ctx, pool.Read, "ovhcloud") is read. When now - lastProcessedAt < frequency * 1000 (default frequency = 86400 s = 1 day), the run exits via notifier.NoWork().
  2. Per-incident dedup — handled at the DB layer. Every insert uses ON CONFLICT DO NOTHING over the natural key, so re-fetching the same incident on a future page-walk is a free no-op.
  3. Pagination cap--max-pages=20 (50 incidents per page × 6 regions = 6000 incident ceiling per run; far above observed throughput).

The --force flag bypasses the freshness gate.

CRIT Staging (Optional)

With --emit-crit=true, per-(cveId, parentSource) CRIT (Cloud Resource Inventory Tag) candidate envelopes are staged via critutil.StageCandidate when the incident name or body mentions an OVHcloud managed product. Keyword routing (in cmd/ovhcloud-json-processor/crit_mapper.go):

Keyword (case-insensitive)CRIT (service, resource_type)
managed kubernetes, managed k8s, mkspublic_cloud_kubernetes / cluster
object storage, public cloud storageobject_storage / bucket
dedicated server, bare-metaldedicated_server / server
vps, public cloud compute, cpanel, plesk (fallback)public_cloud_compute / instance

After the scan loop completes, critpublisher.DrainKeys ingests staged candidates into the cloud-resource inventory. The internal/critutil/dictionaries/extended/ovh.json dictionary must be present at load time — if either spec or extended dictionaries fail to load, --emit-crit falls back to warn-and-ignore (best-effort).

CRIT publication is disabled when CRIT_PUBLISH_DISABLED=true.

Slack Notifications

internal/notify emits the standard four lifecycle events plus per-region warnings via RecordError:

EventTriggerStats
StartedProcess begins
NoWorkTracker fresh & not --force, or zero CVE-mentioning incidents scannedreason string
CompletedClean finish (or partial errors with at least one ref inserted){regionsScanned, incidentsParsed, incidentsFiltered, refsInserted, cvesUnknown, critStaged}
ErroredAll regions errored and zero refs insertedsame stats plus error context
RecordError (warn)Per-region fetch / parse / insert failureone line per region, batched into the closing summary

SetOvertimeCancel wires the notifier to cancel the request context when the soft deadline fires on ECS scheduled runs.

Flags

FlagDefaultDescription
--forcefalseBypass tracker freshness gate
--limit0Cap incidents processed per region (0 = unlimited)
--max-pages20Pagination cap per region (50 incidents per page)
--emit-critfalse (cli) / true (ECS)Stage CRIT candidates when an incident names an OVHcloud managed product

ECS Schedule

PropertyValue
CronRuns weekly on Thursdays at 06:00 UTC (cron(0 6 ? * THU *)).
CPU256
Memory512 MB
Expected duration15 min (six regions × ~20 pages worst-case)

Soft deadline is applied only when EXPECTED_DURATION_MINUTES is set (ECS-only). Local backfills via just go-ovhcloud-json-backfill run to completion per the AGENTS.md backfill rule.

Architecture

flowchart TD A[EventBridge cron 06:00 UTC Thursday] --> B[ECS Fargate task] B --> C{Tracker fresh & not --force?} C -->|yes| Z1[notify NoWork → exit] C -->|no| D[For each region in 6 sub-statuspages] D --> E[GET /api/v2/incidents.json?page=N] E -->|fetch failed| Q0[notify RecordError → next region] E -->|ok| F[ParsePage] F -->|parse failed| Q1[Quarantine parse-error → next region] F -->|incidents empty| N[next region — pagination exhausted] F -->|ok| G[Archive page to S3] G --> H[For each incident] H --> I[ExtractCVEs name + update bodies] I -->|no CVE refs| H I -->|CVEs found| J[SELECT source FROM CVEMetadata WHERE cveId IN] J -->|cve unknown to vdb-manager| K[cvesUnknown++ → next CVE] J -->|ok| L[INSERT CVEMetadataReferences per parent source ON CONFLICT DO NOTHING] L --> M{--emit-crit?} M -->|yes| P[mapOVHToCRIT keyword → StageCandidate] M -->|no| H P --> H H -->|all incidents done| N N -->|all regions done| R[critpublisher.DrainKeys if staged] R --> S[UpsertTracker → notify Completed or Errored]

Data Flow

sequenceDiagram autonumber participant EB as EventBridge participant ECS as ECS task participant API as Statuspage.io API participant DBR as Postgres read participant DBW as Postgres write participant S3 as S3 bucket participant CRIT as critpublisher EB->>ECS: cron 06:00 UTC Thursday trigger ECS->>DBR: GetTracker(ovhcloud) DBR-->>ECS: lastProcessedAt alt fresh & not --force ECS-->>EB: notify NoWork → exit 0 else stale loop For each region × page ECS->>API: GET {region}.status-ovhcloud.com/api/v2/incidents.json?page=N API-->>ECS: incidents[] ECS->>S3: Archive ovhcloud/files/{sha256}/{region}-page-N.json loop For each incident with CVE refs ECS->>DBR: SELECT source FROM CVEMetadata WHERE cveId=$1 DBR-->>ECS: parent sources[] ECS->>DBW: INSERT CVEMetadataReferences ON CONFLICT DO NOTHING opt --emit-crit and product keyword matches ECS->>S3: StageCandidate CRIT envelope end end end ECS->>CRIT: DrainKeys(stagedKeys) CRIT->>DBW: Insert CRIT candidates ECS->>DBW: UpsertTracker(ovhcloud, refsInserted) ECS-->>EB: notify Completed end

Decision Tree

flowchart LR P[Page incident] --> R[ExtractCVEs name + update bodies] R -->|0 CVEs| Skip[Skip — not commentary-worthy] R -->|N CVEs| L[Lookup CVEMetadata sources for each CVE] L -->|cve not in CVEMetadata| U[cvesUnknown++] L -->|K sources found| W[Write K reference rows per CVE] W -->|ON CONFLICT DO NOTHING| Idem[Idempotent — re-runs free] W --> C{Incident body names OVH product?} C -->|no| Done[Reference-only commentary] C -->|yes & --emit-crit| Crit[Stage CRIT candidate]

DB Schema

erDiagram CVEMetadata ||--o{ CVEMetadataReferences : referenced-by CVEMetadata { text cveId PK text source PK } CVEMetadataReferences { uuid uuid PK text cveId FK text source FK text url "incident.shortlink" text type "vendor" text referenceSource "ovhcloud" text title "[region] incident.name" bigint createdAt } BulkDataDumpTracker { text source PK "ovhcloud" bigint lastProcessedAt int frequency "86400" text sha256 int totalCVEs "refsInserted" }

Key Files

FilePurpose
cmd/ovhcloud-json-processor/main.goOrchestration: freshness gate, per-region page-walk, CVE extraction, source lookup, reference insert, tracker bump, notifier
cmd/ovhcloud-json-processor/crit_mapper.goOVHcloud product-keyword → CRIT (service, resource_type) routing
cmd/ovhcloud-json-processor/crit_mapper_test.goRouting-table golden tests
internal/ovhcloud/types.goPage, PageMeta, Incident, IncidentUpdate — only the fields consumed by the enricher are modelled
internal/ovhcloud/parse.goParsePage decoder + ExtractCVEs regex extractor with dedup + upper-case canonicalisation
internal/ovhcloud/client.goIncidentsURL(region, page) + FetchPage using httpclient.New and SetStandardHeaders
internal/ovhcloud/parse_test.goFixture-driven tests over testdata/{region}-incidents.json

Infrastructure

flowchart TD DEV[Developer push to main] --> GHA[GitHub Actions build] GHA --> ECR[ECR push go-ovhcloud-json-processor-latest] ECR --> HOOK[.claude/hooks/post-push-ecr.sh registers new task def revision] HOOK --> TD[ECS task definition go-ovhcloud-json-processor] EB[EventBridge cron 0 6 ? * THU *] --> TD TD --> ECS[ECS Fargate task] ECS --> RW[(RDS write proxy)] ECS --> RR[(RDS read replica)] ECS --> S3[(S3 bucket)] ECS --> SNS[SNS notify topic] TF[terraform/go-schedules.tf module ovhcloud_json_processor] -.applies.-> TD TF -.applies.-> EB

Future Work

  • Same-CVE region dedup — when both bare-metal-servers and customer-service open separate incidents for the same upstream CVE (e.g. cPanel cross-product impact), the reference rows are intentionally kept distinct so each shortlink remains addressable. A future post-processor could collapse these into a single reference per (cveId, parentSource) with a JSON array of incident shortlinks if the CVE detail page UI starts to feel cluttered.
  • ovh.json extended dictionary — required at load time when --emit-crit=true. Authoring is tracked separately from this processor; until the dictionary lands the CRIT pass is a no-op warn-only.
  • Help-centre commentaryhelp.ovhcloud.com carries unstructured Meltdown / Spectre style write-ups that are not indexed in /api/v2/incidents.json. Out of scope for the JSON enricher.

S3 Persistence

  • Archive path: ovhcloud/files/{sha256}/{filename}
  • Quarantine path: failed-feeds/ovhcloud-json-processor/{YYYY-MM-DD}/{reason}/{filename}
  • Failure reasons emitted: parse-error

Uses s3client.Uploader from internal/s3client/uploader.go. Skipped when S3_BUCKET_NAME is unset (local dev).

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

See the S3 Persistence Contract for the full reason taxonomy.