ovhcloud-json-processor
Status: Live Source: OVHcloud Statuspage.io sub-instances at
{slug}.status-ovhcloud.com/api/v2/incidents.jsonType:json(commentary enricher — no primary CVEMetadata writes) Source slug:ovhcloudSchedule: 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
| Property | Value |
|---|---|
| Endpoint shape | https://{slug}.status-ovhcloud.com/api/v2/incidents.json |
| Sub-instances | bare-metal-servers, customer-service, hosted-private-cloud, network, public-cloud, web-cloud |
| Auth | None — public Statuspage.io REST |
| Format | JSON; page, incidents[] (50 per page), incident_updates[] per incident |
| Pagination | ?page=N query param; iterate until response carries zero incidents |
| Anti-bot | None — standard Atlassian Statuspage product |
| Cadence | Weekly run on Thursdays at 06:00 UTC; volume ~10–30 CVE-mentioning incidents per year across all regions |
| Identifier shape | No 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:
| Helper | Purpose |
|---|---|
ParsePage(b []byte) (*Page, error) | Decode one /api/v2/incidents.json response; schema-tolerant — only the fields actually consumed are modelled |
ExtractCVEs(inc Incident) []string | Run 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.
| Table | Rows inserted | Fields |
|---|---|---|
CVEMetadataReferences | One per (cveId, parentSource) pair × incident | cveId, source = parentSource, url = incident.shortlink, type = "vendor", referenceSource = "ovhcloud", title = "[{region}] {incident.name}" |
BulkDataDumpTracker | One 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:
- Tracker freshness gate — at startup,
db.GetTracker(ctx, pool.Read, "ovhcloud")is read. Whennow - lastProcessedAt < frequency * 1000(default frequency = 86400 s = 1 day), the run exits vianotifier.NoWork(). - Per-incident dedup — handled at the DB layer. Every insert uses
ON CONFLICT DO NOTHINGover the natural key, so re-fetching the same incident on a future page-walk is a free no-op. - 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, mks | public_cloud_kubernetes / cluster |
object storage, public cloud storage | object_storage / bucket |
dedicated server, bare-metal | dedicated_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:
| Event | Trigger | Stats |
|---|---|---|
Started | Process begins | — |
NoWork | Tracker fresh & not --force, or zero CVE-mentioning incidents scanned | reason string |
Completed | Clean finish (or partial errors with at least one ref inserted) | {regionsScanned, incidentsParsed, incidentsFiltered, refsInserted, cvesUnknown, critStaged} |
Errored | All regions errored and zero refs inserted | same stats plus error context |
RecordError (warn) | Per-region fetch / parse / insert failure | one 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
| Flag | Default | Description |
|---|---|---|
--force | false | Bypass tracker freshness gate |
--limit | 0 | Cap incidents processed per region (0 = unlimited) |
--max-pages | 20 | Pagination cap per region (50 incidents per page) |
--emit-crit | false (cli) / true (ECS) | Stage CRIT candidates when an incident names an OVHcloud managed product |
ECS Schedule
| Property | Value |
|---|---|
| Cron | Runs weekly on Thursdays at 06:00 UTC (cron(0 6 ? * THU *)). |
| CPU | 256 |
| Memory | 512 MB |
| Expected duration | 15 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
Data Flow
Decision Tree
DB Schema
Key Files
| File | Purpose |
|---|---|
cmd/ovhcloud-json-processor/main.go | Orchestration: freshness gate, per-region page-walk, CVE extraction, source lookup, reference insert, tracker bump, notifier |
cmd/ovhcloud-json-processor/crit_mapper.go | OVHcloud product-keyword → CRIT (service, resource_type) routing |
cmd/ovhcloud-json-processor/crit_mapper_test.go | Routing-table golden tests |
internal/ovhcloud/types.go | Page, PageMeta, Incident, IncidentUpdate — only the fields consumed by the enricher are modelled |
internal/ovhcloud/parse.go | ParsePage decoder + ExtractCVEs regex extractor with dedup + upper-case canonicalisation |
internal/ovhcloud/client.go | IncidentsURL(region, page) + FetchPage using httpclient.New and SetStandardHeaders |
internal/ovhcloud/parse_test.go | Fixture-driven tests over testdata/{region}-incidents.json |
Infrastructure
Future Work
- Same-CVE region dedup — when both
bare-metal-serversandcustomer-serviceopen 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.jsonextended 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 commentary —
help.ovhcloud.comcarries 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).
See the S3 Persistence Contract for the full reason taxonomy.