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 CVEMetadata rows. 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 CVEMetadataReferences row per (cveId, source) tuple already present in CVEMetadata (the post is a fact about the CVE, not about any particular ingesting source’s representation of it). Rows carry type="third-party-analysis" and referenceSource="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-crit and on a successful dictionary resolution for (digitalocean, service, resource_type).

Source

PropertyValue
Listing URLhttps://www.digitalocean.com/blog?topic=security
AuthNone — fully public
FormatNext.js server-rendered HTML (no RSS, no JSON API)
PaginationNone — the topic filter renders all matches on one page
Per-post URL/blog/{slug}
Identifier shapeNone 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:

SignalElement
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 referencesinline 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):

PatternPurpose
blogLinkRehref="/blog/{slug}" extraction from the listing page
articleTitleRe<h1> content on detail pages
titleTagRe + titleSuffixReFallback <title> with " | DigitalOcean" suffix stripped
pubDateRe<time dateTime="YYYY-MM-DD…"> ISO date
cveReCVE-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:

ColumnValue
uuiduuid.New()
cveIdextracted CVE id (uppercase)
sourceinherited from the parent CVEMetadata row (fan-out across all sources)
urlcanonical blog post URL
type"third-party-analysis"
referenceSource"digitalocean"
titlepost <h1> text
createdAttime.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: BulkDataDumpTracker row keyed by source="digitalocean". When now - lastProcessedAt < frequencyMs the run exits via notifier.NoWork. --force bypasses the gate.
  • Soft deadline: when EXPECTED_DURATION_MINUTES is set (ECS schedule), the loop stops cleanly at expected - 10 minutes. 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 EXISTS guard 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:

FieldValue
vex_statusnot_affected
shared_responsibilityprovider_only
fix_propagationautomatic
existing_deployments_remain_vulnerablefalse
resource_lifecyclestateful_managed
service_available_date2012-01-15 (DO public GA)

Product keyword → (service, resource_type) map (slugs match the upstream CRIT spec dictionary):

KeywordServiceResource type
managed kubernetes, digitalocean kubernetes, dokskubernetescluster
app platformapp_platformapp
managed databases, managed databasedatabasedb_instance
spacesspacesbucket
droplets, dropletdropletinstance

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

ConcernPath
Archive (success)digitalocean/files/{sha256}/{slug}.html
Quarantinenot 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:

  • Started at pipeline entry
  • RecordError(detail) per per-post failure (fetch, source lookup, insert)
  • NoWork when the freshness gate fires or every parsed post was out-of-scope marketing
  • Errored when at least one post failed terminally (exits 1)
  • Completed with {fetched, postsParsed, postsSkipped, postsFailed, refsInserted, cvesUnknown, critStaged}

Flags

FlagDefaultEffect
--forcefalseBypass the freshness gate
--limit N0 (all)Process at most N posts
--emit-critfalse (CLI) / true (ECS schedule)Stage CRIT envelopes when a DO product is named and the dict resolves

ECS Schedule

PropertyValue
Familygo-digitalocean-fetch-processor
CronRuns weekly on Wednesdays at 05:00 UTC (cron(0 5 ? * WED *)).
CPU256
Memory512 MB
expected_duration_minutes15
Command["/app/digitalocean-fetch-processor", "--emit-crit=true"]

Architecture

flowchart TD A[ECS task starts] --> B{freshness gate} B -->|fresh| Z[NoWork → exit] B -->|stale or --force| C[FetchListing] C --> D[ParseListing → URLs] D --> E{for each URL} E --> F[FetchDetail] F --> G[ParseDetail] G -->|no CVEs| E G -->|CVEs| H[Archive HTML to S3] H --> I[loadAllSourcesForCVEs] I --> J[insertCommentaryRefs] J --> K{--emit-crit?} K -->|no| E K -->|yes| L[mapDigitalOceanToCRIT] L -->|product matched| M[StageCandidate to S3] L -->|no product| E M --> E E -->|done| N[critpublisher.DrainKeys] N --> O[UpsertTracker] O --> P[Completed → exit]
flowchart TD START[Detail page parsed] --> CVES{ParseDetail ok?} CVES -->|no CVEs| OOS[Out-of-scope, postsSkipped++] CVES -->|CVEs found| LOOKUP[loadAllSourcesForCVEs] LOOKUP --> KNOWN{CVE has CVEMetadata row?} KNOWN -->|no| UNKNOWN[cvesUnknown++, no ref written] KNOWN -->|yes| REF[Insert CVEMetadataReferences per source
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

PathRole
scripts/go-processors/cmd/digitalocean-fetch-processor/main.goOrchestration, freshness gate, batch loop, ref insert, CRIT fan-out
scripts/go-processors/cmd/digitalocean-fetch-processor/crit_mapper.goProduct-keyword → (service, resource_type) table; envelope build
scripts/go-processors/internal/digitalocean/types.goPost, CommentaryRef, source constants
scripts/go-processors/internal/digitalocean/client.goFetchListing, FetchDetail (HTTP + headers)
scripts/go-processors/internal/digitalocean/parse.goRegex extractors, ParseListing, ParseDetail, ContentHash
scripts/go-processors/internal/digitalocean/map.goToCommentaryRefs flattening helper
scripts/go-processors/internal/digitalocean/testdata/Captured listing + detail HTML fixtures
CRIT dictionaryupstream ietf-crit-spec v0.3.x (dictionaries/digitalocean.json) — no in-repo extended dict needed

Future Work

  • Wire Quarantine calls 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).

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

See the S3 Persistence Contract for the full reason taxonomy.