digitalocean-fetch-processor
Status: Live (commentary enricher) Source: DigitalOcean Blog — Security topic Type:
fetch(HTML scraping) Source slug:digitalocean
Overview
DigitalOcean has no PSIRT and no first-party CVE feed. The
digitalocean.com/blog?topic=security page surfaces blog posts that
occasionally comment on third-party CVEs affecting infrastructure DO
runs on behalf of customers — kernel CVEs, OpenSSH (regreSSHion-class),
Postgres, runc / containerd, etc. — and announce DO’s response on
Droplets, Managed Kubernetes, Managed Databases, and friends.
This processor therefore is a third-party-analysis reference
enricher, not an advisory producer. It mirrors the
fastly-fetch-processor precedent exactly:
- It never creates
CVEMetadatarows. A DO commentary post is too weak a signal to seed a canonical CVE record on its own. - For each CVE-mentioning post, it inserts one
CVEMetadataReferencesrow per(cveId, source)tuple already present inCVEMetadata(the post is a fact about the CVE, not about any particular ingesting source’s representation of it). Rows carrytype="third-party-analysis"andreferenceSource="digitalocean". - CVEs not yet known to vdb-manager are silently skipped, counted as
cvesUnknown. - When a post names a DigitalOcean product mitigation, the processor
optionally stages CRIT candidates with
vex_status="not_affected"(the provider sits upstream of the vulnerable component) gated on--emit-critand on a successful dictionary resolution for(digitalocean, service, resource_type).
Source
| Property | Value |
|---|---|
| Listing URL | https://www.digitalocean.com/blog?topic=security |
| Auth | None — fully public |
| Format | Next.js server-rendered HTML (no RSS, no JSON API) |
| Pagination | None — the topic filter renders all matches on one page |
| Per-post URL | /blog/{slug} |
| Identifier shape | None DO-issued; CVEs extracted by CVE-YYYY-NNNN+ regex |
| Cadence | ~3–5 CVE-mentioning posts per year |
The marketing-compliance landing at digitalocean.com/security points
to the Intigriti bug-bounty program and is not a feed. The operational
status.digitalocean.com/history.rss is RSS but contains zero CVE
references — incidents only.
Page Structure
Listing page — <a href="/blog/{slug}"> anchors are emitted
server-side. The processor excludes /blog/tags/...,
/blog/category/..., /blog/author/..., and /blog/topic/...
aggregation paths.
Detail page — three signals of interest:
| Signal | Element |
|---|---|
| Title | <h1 class="Typography…">{title}</h1> (fallback: <title>{title} | DigitalOcean</title>) |
| Publish date | <time dateTime="YYYY-MM-DDTHH:MM:SS.sssZ" itemProp="datePublished">{human date}</time> |
| CVE references | inline body text (CVE-YYYY-NNNN+) |
Parsing
internal/digitalocean/parse.go uses compiled regexp (no DOM tree —
the Next.js server-rendered HTML is large but the signals are stable):
| Pattern | Purpose |
|---|---|
blogLinkRe | href="/blog/{slug}" extraction from the listing page |
articleTitleRe | <h1> content on detail pages |
titleTagRe + titleSuffixRe | Fallback <title> with " | DigitalOcean" suffix stripped |
pubDateRe | <time dateTime="YYYY-MM-DD…"> ISO date |
cveRe | CVE-YYYY-NNNN+ extraction (uppercased, deduped) |
ParseListing returns absolute post URLs, excluding aggregation
prefixes. ParseDetail returns ok=false when no CVE references are
found — those posts are out of scope.
Storage
CVEMetadataReferences row shape per emitted reference:
| Column | Value |
|---|---|
uuid | uuid.New() |
cveId | extracted CVE id (uppercase) |
source | inherited from the parent CVEMetadata row (fan-out across all sources) |
url | canonical blog post URL |
type | "third-party-analysis" |
referenceSource | "digitalocean" |
title | post <h1> text |
createdAt | time.Now().UnixMilli() |
CVEMetadataReferences has no unique constraint covering
(cveId, source, url, referenceSource) — only plain indexes — so ON CONFLICT
cannot dedupe. The insert is therefore guarded by an explicit
WHERE NOT EXISTS (…) sub-select (main.go:352-360) and inserted is counted
from tag.RowsAffected(), which makes a re-run over an unchanged post a true
no-op. (fastly-fetch-processor still uses the bare ON CONFLICT DO NOTHING
form and duplicates on every run — do not copy it.)
CVEMetadata writes: none. CVEAlias writes: none.
CritRecord writes: yes, indirectly — see CRIT Staging below.
Identifier Policy
No DigitalOcean-issued identifiers exist; the processor extracts CVE
references via CVE-\d{4}-\d{4,} and ignores everything else. Posts
with zero CVE matches are not stored.
Incremental Strategy
- Freshness gate:
BulkDataDumpTrackerrow keyed bysource="digitalocean". Whennow - lastProcessedAt < frequencyMsthe run exits vianotifier.NoWork.--forcebypasses the gate. - Soft deadline: when
EXPECTED_DURATION_MINUTESis set (ECS schedule), the loop stops cleanly atexpected - 10minutes. Local backfills unset the var (see feedback_backfill_no_deadline). - Idempotency: re-running over the same posts produces zero new
rows on the second pass — the
WHERE NOT EXISTSguard on the reference insert dedupes on(cveId, source, url, referenceSource).
CRIT Staging
Optional, gated on --emit-crit. When a post body contains a keyword
matching a DigitalOcean product, the processor resolves
(digitalocean, service, resource_type) against the CRIT spec
dictionary (ietf-crit-spec v0.3.x) and stages one CRIT envelope per
(cveId, CVEMetadata-source) pair with:
| Field | Value |
|---|---|
vex_status | not_affected |
shared_responsibility | provider_only |
fix_propagation | automatic |
existing_deployments_remain_vulnerable | false |
resource_lifecycle | stateful_managed |
service_available_date | 2012-01-15 (DO public GA) |
Product keyword → (service, resource_type) map (slugs match the
upstream CRIT spec dictionary):
| Keyword | Service | Resource type |
|---|---|---|
managed kubernetes, digitalocean kubernetes, doks | kubernetes | cluster |
app platform | app_platform | app |
managed databases, managed database | database | db_instance |
spaces | spaces | bucket |
droplets, droplet | droplet | instance |
Posts that name “load balancer” or “VPC” pass the keyword filter but fail the dictionary gate (no spec entry yet), so they produce a reference row but no CRIT envelope. Pure-commentary posts (no DO product keyword at all) likewise produce reference rows only.
S3 Persistence
| Concern | Path |
|---|---|
| Archive (success) | digitalocean/files/{sha256}/{slug}.html |
| Quarantine | not yet implemented |
The raw post HTML is content-addressed on success via
uploader.ArchiveRecord(ctx, "digitalocean", payload). Failure-path
quarantine is non-compliant against the
S3 Persistence Contract;
see the
compliance matrix for
the open item.
Slack Notifications
Standard envelope via internal/notify:
Startedat pipeline entryRecordError(detail)per per-post failure (fetch, source lookup, insert)NoWorkwhen the freshness gate fires or every parsed post was out-of-scope marketingErroredwhen at least one post failed terminally (exits 1)Completedwith{fetched, postsParsed, postsSkipped, postsFailed, refsInserted, cvesUnknown, critStaged}
Flags
| Flag | Default | Effect |
|---|---|---|
--force | false | Bypass the freshness gate |
--limit N | 0 (all) | Process at most N posts |
--emit-crit | false (CLI) / true (ECS schedule) | Stage CRIT envelopes when a DO product is named and the dict resolves |
ECS Schedule
| Property | Value |
|---|---|
| Family | go-digitalocean-fetch-processor |
| Cron | Runs weekly on Wednesdays at 05:00 UTC (cron(0 5 ? * WED *)). |
| CPU | 256 |
| Memory | 512 MB |
expected_duration_minutes | 15 |
| Command | ["/app/digitalocean-fetch-processor", "--emit-crit=true"] |
Architecture
type=third-party-analysis
referenceSource=digitalocean] REF --> CRIT{--emit-crit?} CRIT -->|no| DONE[refsInserted++] CRIT -->|yes| PRODUCT{DO product keyword
in post body?} PRODUCT -->|no| DONE PRODUCT -->|yes| DICT{dict.Resolve
(digitalocean, service, rt) found?} DICT -->|no| DONE DICT -->|yes| STAGE[Stage CRIT envelope per (cveId, source)
vex_status=not_affected
fix_propagation=automatic] STAGE --> DONE
Key Files
| Path | Role |
|---|---|
scripts/go-processors/cmd/digitalocean-fetch-processor/main.go | Orchestration, freshness gate, batch loop, ref insert, CRIT fan-out |
scripts/go-processors/cmd/digitalocean-fetch-processor/crit_mapper.go | Product-keyword → (service, resource_type) table; envelope build |
scripts/go-processors/internal/digitalocean/types.go | Post, CommentaryRef, source constants |
scripts/go-processors/internal/digitalocean/client.go | FetchListing, FetchDetail (HTTP + headers) |
scripts/go-processors/internal/digitalocean/parse.go | Regex extractors, ParseListing, ParseDetail, ContentHash |
scripts/go-processors/internal/digitalocean/map.go | ToCommentaryRefs flattening helper |
scripts/go-processors/internal/digitalocean/testdata/ | Captured listing + detail HTML fixtures |
| CRIT dictionary | upstream ietf-crit-spec v0.3.x (dictionaries/digitalocean.json) — no in-repo extended dict needed |
Future Work
- Wire
Quarantinecalls on fetch/parse failures to close the S3 contract gap. - Add YARA/Snort detection queries once a CRIT envelope is staged for
real (currently
pending_reason="query_in_development"). - Reassess if DigitalOcean ever publishes a structured advisory feed — that would supersede this commentary-only path.
S3 Persistence
- Archive path:
digitalocean/files/{sha256}/{filename}✓ - Quarantine path:
failed-feeds/digitalocean-fetch-processor/{YYYY-MM-DD}/{reason}/{filename}⚠ not yet wired - Failure reasons emitted:
fetch-error,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.